diff --git a/packages/agent/README.md b/packages/agent/README.md index 9d23a3bc5..2d10471c5 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -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 diff --git a/packages/agent/docs/plugin-reloading.md b/packages/agent/docs/plugin-reloading.md deleted file mode 100644 index 8c3425583..000000000 --- a/packages/agent/docs/plugin-reloading.md +++ /dev/null @@ -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. diff --git a/packages/agent/docs/plugins.md b/packages/agent/docs/plugins.md index 2a3eac3da..8da04f06a 100644 --- a/packages/agent/docs/plugins.md +++ b/packages/agent/docs/plugins.md @@ -1,16 +1,16 @@ -# Coding-Agent Application Hosts and Plugin Facets +# Coding-Agent Application Hosts and Facets -> **Status:** Tentative design input, not a normative contract or implementation handoff. The transport-neutral service token, provider, singleton `use()`, keyed `observe()`, and `ReplicatedState` substrate now have an experimental implementation, including `Models`, `Chat`, `SessionDirectory`, and `SessionManagement` vertical slices. The remaining documented built-in service contracts retain explicit `ServiceSliceNotImplemented` members. The application facet environment, runtime facets, plugin kernel, references, telemetry propagation, and most other example facets remain illustrative. Plugin reload semantics are specified separately in [`plugin-reloading.md`](plugin-reloading.md). Reconcile this design with `rpc.md`, `telemetry.md`, and the final harness contract before adding it to `harness.md` or creating a work package. +> **Status:** Design specification for the experimental facet and service architecture. -This document assumes you already understand `AgentHarness`, `AgentLane`, `Session`, `Branch`, `SessionRepo`, invocation `Context`, and telemetry. Read `rpc.md` for wire frames, remote references, subscriptions, and trace carriers, `telemetry.md` for context propagation and cancellation semantics, and `plugin-reloading.md` for manifest-generation replacement and durable reconstruction. +This document assumes you already understand `AgentHarness`, `AgentLane`, `Session`, `Branch`, `SessionRepo`, and invocation `Context`. Read `rpc.md` for service transport semantics and `telemetry.md` for the telemetry model. ## Bird's-eye view -The new coding agent is assembled from an ordered plugin manifest, not a monolith with an extension API bolted on. Three layers: +The coding agent is assembled independently in several processes. Three layers: -1. The **plugin kernel** owns generic lifecycle mechanics: ordered setup, activation, scoped resource ownership, setup-failure cleanup, and reverse-order disposal. It knows nothing about Harness, tools, TUI components, RPC services, connections, or coding-agent policy. -2. An **application host** owns one concrete runtime and contributes a runtime facet that provides its concrete services. The **session host** normally runs in a dedicated session worker and owns session authority — the real Harness. A **presentation host** (TUI today, web later) owns a user interface. A **server host** owns server-wide authority: session records (`SessionRepo`), session-worker management, authentication, attachment, and routing between presentations and session workers. -3. A **plugin package** implements one feature as one or more host-specific **facets** — setup functions, one per host kind. Each facet can use only the services available in its host instance and process. +1. The **facet kernel** owns service-aware lifecycle mechanics: synchronous setup, dependency assembly, local and connected service binding, activation, scoped resource ownership, setup-failure cleanup, reload, and reverse-order disposal. It knows about services and connections, but not about Harness, tools, TUI components, or coding-agent policy. +2. An **application host** owns one concrete runtime and contributes runtime facets that provide its concrete services. The **session host** normally runs in a dedicated session worker and owns session authority — the real Harness. A **presentation host** (TUI today, web later) owns a user interface. A **server host** owns server-wide authority: session records (`SessionRepo`), session-worker management, authentication, attachment, and routing between presentations and session workers. +3. An **extension** may distribute independent host-specific bundles containing **facets**. No aggregate extension object is loaded into all processes. Each host loads only facets built for that process and those facets can use only services available in that host graph. The initial topology has one server and no server-to-server links: @@ -26,49 +26,46 @@ A presentation and a session worker each connect to the server. There is no dire A session worker normally owns one session, and each session facet is instantiated for that session. A server facet is instantiated once per server process and is shared across every session and presentation connected to it. Server facets should therefore be rare and limited to inherently server-wide concerns. Per-session feature state belongs in session facets; dedicated workers provide the preferred lifecycle, crash, and state isolation. Future co-location may preserve the same logical service graph without changing what objects a facet can access. -**Host** and **client** are roles per connection, not fixed process kinds. The server hosts presentation and session-worker connections. A session worker serves its provided session services and may consume server-provided services over the same RPC binding mechanism. "Client" below always names the connection role, never a kind of plugin. +**Host** and **client** are roles per connection, not fixed process kinds. The server hosts presentation and session-worker connections. A session worker serves its provided session services and may consume server-provided services over the same RPC binding mechanism. "Client" below always names the connection role, never a kind of extension. ## Why this shape -- **Authority stays where it belongs.** Provider credentials, tool execution, and per-session plugin data exist only in the session worker; session records and worker control exist only in the server. Nothing reaches a presentation except through a deliberate contract. -- **One feature stays coherent.** The question plugin's tool, dialog, and renderer ship in one package around one JSON contract, yet each facet is host-native code. +- **Authority stays where it belongs.** Provider credentials, tool execution, and per-session extension data exist only in the session worker; session records and worker control exist only in the server. Nothing reaches a presentation except through a deliberate contract. +- **One feature stays coherent.** The question extension's tool, dialog, and renderer ship in one package around one JSON contract, yet each facet is host-native code. - **A new surface is presentation-only work.** A web facet for the question dialog or the session picker registers against existing tokens; session and server code do not change. - **Server state stays server-wide.** A server facet is shared by all sessions and clients, so features use one only when their authority is inherently global to that server. -- **No privileged built-ins.** Built-ins, runtime facets, and third-party plugins use the same environment, so shipping the product continuously exercises the extension API. +- **One facet mechanism.** Built-ins, runtime capabilities, and extensions use the same facet environment in every host. - **Testable in pieces.** A facet tests against service-providing fixtures, a contract against loopback, and a routed TUI → server → session-worker path against a real transport — independently. -## One feature, several host facets +## One feature, several independently loaded facets -A coding-agent plugin is a feature bundle that may provide a facet for any host: +There is deliberately no `CodingAgentPlugin` runtime interface. A server, Session worker, TUI, and future web host execute different bundles in different processes, so they cannot share one loaded object containing all host facets. + +The in-process unit is one facet: ```ts -interface CodingAgentPlugin { +interface Facet { readonly id: string; - readonly server?: PluginFacet; - readonly session?: PluginFacet; - readonly tui?: PluginFacet; - readonly web?: PluginFacet; + setup(env: FacetEnvironment): void; } - -type PluginFacet = (env: FacetEnvironment) => void; ``` -`PluginFacet` is the only plugin-facing shape the generic kernel needs. Each process loads its complete facet set: the server loads server facets, each Session worker loads Session facets, and each presentation loads its TUI or web facets. The host resolves every `env.use()` across facet-provided and connected services. Facets are optional: a `models.json` plugin has only a Session facet, a terminal theme only a TUI facet, and the Session directory has a server facet plus a TUI facet. Setup is a synchronous declaration phase; asynchronous initialization belongs in `onActivate`. Examples use an illustrative `definePlugin()` helper returning a `CodingAgentPlugin` — the loaded, in-process shape. +Each process loads an ordered `Facet[]` appropriate to that process. Setup is synchronous declaration; asynchronous initialization belongs in `onActivate()`. A feature may consist of a shared contract bundle plus zero or more separately resolved server, Session, TUI, or web bundles. Their shared service IDs and wire contracts connect them, not an aggregate JavaScript object or a `definePlugin()` wrapper. A package keeps shared wire contracts separate from host dependencies: ```text -question-plugin/ +question-extension/ contract.ts JSON DTOs and service tokens session.ts dialog-service authority and tool contribution; imports agent/session code tui.ts terminal dialog and renderer; imports TUI code web.ts optional browser dialog and renderer - index.ts loopback bundle; production hosts resolve one entry point per process kind + package exports unresolved mapping from host kind to independently loadable bundles ``` The browser build never imports `session.ts`; the session process never imports TUI or DOM code. -The question plugin is this document's end-to-end example: +The question extension is this document's end-to-end example: ```text model calls the question tool (session facet) @@ -81,107 +78,32 @@ model calls the question tool (session facet) With no presentation connected, the question remains pending. A TUI or web facet that connects later obtains the same pending question. -The models service in the next sections illustrates services and replicated state; the [server section](#the-server-directory-management-and-routing) covers server-wide services and session routing; the [question section](#session-owned-deferred-interactions-the-question-plugin) makes the full round trip concrete. +The models service in the next sections illustrates services and replicated state; the [server section](#the-server-directory-management-and-routing) covers server-wide services and session routing; the [question section](#session-owned-deferred-interactions-the-question-extension) makes the full round trip concrete. -## Starting and connecting hosts +## Loading and connecting hosts -Every host receives the same **logical manifest**: JSON-safe plugin identity, order, and version. It resolves only its own package entry points (`./server`, `./session`, `./tui`, or `./web`); a missing entry point means that plugin has no facet for the host. Resolution, not convention, enforces the dependency boundary. +The loader abstraction is intentionally smaller than an extension manifest: ```ts -const manifest: PluginManifestEntry[] = [ - { id: "@pi/session-core" }, - { id: "@pi/providers-builtin" }, - { id: "@pi/model-selection" }, - { id: "@pi/question" }, -]; -``` +interface LoadedFacets { + readonly facets: readonly Facet[]; + dispose(): Promise; +} -Presentation and session-worker hosts do not discover servers or open sockets themselves. They receive one connection function; the server host receives a listener: - -```ts -type Connect = (signal: AbortSignal) => Promise; -type Listen = (accept: (transport: Transport) => void) => () => void; - -interface Transport { - send(frame: JsonValue): void; - receive(listener: (frame: JsonValue) => void): void; - close(): void; - readonly closed: Promise; +interface FacetLoader { + load(): Promise; } ``` -`Connect` performs one connection attempt. Its closure owns the endpoint and credential, so the host never reads an address, environment variable, or secret. `Listen` lets the server accept presentation and session-worker connections. Both operate on the same bidirectional framed `Transport`. +Each host receives one or more static, combined, or extension-backed loaders. A loader owns the resources for one loaded module generation; the facet host owns the active facet environments. Initial startup loads facets, assembles the service graph, activates it, and disposes the loaded generation only after the host retires. -```ts -declare const tuiAppHost: { - start(options: { manifest: Manifest; connect: Connect }): Promise; -}; +An extension resolver may add identity, ordering, version selection, package isolation, and process-specific source resolution. Its output remains independent `FacetLoader` inputs for each process rather than one cross-process extension object. -declare const sessionAppHost: { - start(options: { manifest: Manifest; connect: Connect; identity: SessionIdentity }): Promise; -}; +After transport setup, a host gives the kernel its loaded facets, runtime facets that provide concrete local services, and any host-selected service connections. The kernel runs every `setup()` in loader order, validates the complete service graph, binds dependencies, and then activates providers before consumers. Setup failure and normal shutdown dispose resources in reverse dependency order. -declare const serverAppHost: { - start(options: { manifest: Manifest; listen: Listen }): Promise; -}; +Exactly one process owns a Session's authority at a time. Worker replacement must close the old owner before a new process opens the same durable Session. Each presentation and Session worker uses one multiplexed connection to its server; facets never open private sockets or handle request IDs, cancellation frames, routing namespaces, or reconnect buffering. -interface RunningHost { - readonly connection: ReplicatedState; - stop(): Promise; -} - -interface RunningServer { - readonly local: Connect; // connect another host in the same process - stop(): Promise; -} -``` - -Every deployment uses those three entry points: - -```ts -// separate processes -await serverAppHost.start({ manifest, listen: listenSocket(socketPath) }); -await sessionAppHost.start({ manifest, identity, connect: dialSocket(endpoint, token) }); -await tuiAppHost.start({ manifest, connect: dialSocket(endpoint, token) }); - -// standalone: the same hosts and boundaries, without sockets -const server = await serverAppHost.start({ manifest, listen: listenNothing() }); -await sessionAppHost.start({ manifest, identity, connect: server.local }); -await tuiAppHost.start({ manifest, connect: server.local }); -``` - -This keeps deployment policy outside plugins: - -- reconnecting means calling `connect(signal)` again; the host owns retry timing, while `Connect` owns one attempt; -- the accepting server assigns peer identity during its handshake from the presented credential; `SessionIdentity` identifies the durable session being run, not a trusted claim about the connection; -- `server.local` makes standalone a deployment, not a separate mode or code path. - -The only connection state exposed to facets is: - -```ts -type ConnectionState = - | { status: "connecting"; attempt: number } - | { status: "connected"; since: string } - | { status: "disconnected"; since: string; reason: string; retryAt: string | null }; -``` - -After transport setup, each host gives the kernel its loaded facets plus a runtime facet that provides concrete host services. The kernel invokes every facet against the resulting service graph: - -```ts -interface FacetLifecycle { - onActivate(callback: () => void | Promise): void; - onDeactivate(callback: () => void | Promise): void; - own(disposal: () => void | Promise): void; -} -``` - -Host infrastructure calls `own()` for every service, contribution, or subscription registered through the facet environment. Facets first register in manifest order; then `onActivate` callbacks start effects and the host exposes state snapshots. Failure and normal shutdown dispose environments in reverse dependency order. Duplicate services, missing dependencies, and dependency cycles are application-assembly errors. - -The prototype in `packages/coding-agent/test/fixtures/plugin-app/` demonstrates the composition model with an ad hoc RPC implementation; the shared RPC design in `rpc.md` should replace that transport without replacing the host/facet architecture. - -Exactly one process owns a Session's authority at a time. Worker replacement closes the old owner before a new process opens the same durable Session. Each presentation and session worker has one multiplexed connection to its server; plugins never open private sockets. Plugin authors never implement request IDs, sockets, trace carriers, cancellation frames, remote-reference registries, or reconnect buffering. - -Out of scope: arbitrary undeclared object remoting, serialized functions/classes/`Map`/`Set`, remote hook or tool execution, offline presentation writes or automatic mutation replay, a universal remote `AgentHarness` or serialized UI tree, and re-exposing forwarded frames, proxies, or references as plugin-visible objects. +Out of scope: arbitrary undeclared object remoting, serialized functions/classes/`Map`/`Set`, remote hook or tool execution, offline presentation writes or automatic mutation replay, a universal remote `AgentHarness`, and a serialized UI tree. ## Services connect host facets @@ -199,13 +121,13 @@ interface ServiceInstances { } ``` -TypeScript types cannot produce runtime member metadata. Plugin authors nevertheless declare no parallel member descriptor. When an exposed `provide()` or `ServiceInstances.add()` implementation reaches the remote-service boundary, the runtime classifies functions as remote methods and recognizes branded `ReplicatedState` values. It rejects unsupported members and announces the resulting member table over the transport. Services used only inside one host may use arbitrary object contracts. +TypeScript types cannot produce runtime member metadata. Facet authors nevertheless declare no parallel member descriptor. When an exposed `provide()` or `ServiceInstances.add()` implementation reaches the remote-service boundary, the runtime classifies functions as remote methods and recognizes branded `ReplicatedState` values. It rejects unsupported members and announces the resulting member table over the transport. Process-local services may use arbitrary object contracts. -`use()` on a singleton returns a stable lazy proxy synchronously, even before a remote provider is attached. Member access creates local method, state, or event slots as they are used; attachment validates those slots against the provider-announced kinds. A mismatch is an assembly or protocol error. This runtime mechanism is implemented once by the host rather than repeated in every service declaration. +`use()` on a singleton returns a stable lazy proxy synchronously, even before a remote provider is attached. Member access creates local method or state slots as they are used; attachment validates those slots against the provider-announced kinds. A mismatch is an assembly or protocol error. This runtime mechanism is implemented once by the host rather than repeated in every service declaration. ### Dependency declaration and assembly -Service API calls made during facet setup are the dependency declarations. The kernel does not reflect on erased TypeScript interfaces, and plugin authors do not maintain parallel `requires` and `provides` lists. A `Service` retains its stable ID at runtime, and the API call supplies the mode. `use()` and `observe()` initially return source-independent disconnected handles. After all setup completes, the host matches unresolved requirements against provider-generated connection catalogues and binds each token to its local provision or exactly one connection. +Service API calls made during facet setup are the dependency declarations. The kernel does not reflect on erased TypeScript interfaces, and facet authors do not maintain parallel `requires` and `provides` lists. A `Service` retains its stable ID at runtime, and the API call supplies the mode. `use()` and `observe()` initially return source-independent disconnected handles. After all setup completes, the host matches unresolved requirements against provider-generated connection catalogues and binds each token to its local provision or exactly one connection. The host records a private generation-scoped ledger: @@ -225,9 +147,9 @@ env.observe(QuestionDialogs, handler) The first `provide()`, `provideMany()`, `use()`, or `observe()` for a token must occur during facet setup. Commands, hooks, event handlers, and activation callbacks use handles acquired during setup; they cannot introduce an undeclared service dependency later. Dynamic instances use the setup-owned `ServiceInstances` handle, so adding and closing instances do not change the graph. -After every facet has registered, the host generates its outgoing catalogue from RPC-capable provisions, obtains catalogues from its connections, resolves requirements to local or connected provisions, rejects missing providers, duplicate offers or singleton owners, singleton/keyed mismatches, invalid dependency cycles, and invalid RPC service implementations, then records consumer-to-provider edges for lifecycle ordering. `use()` and `observe()` declare hard requirements; optional dependencies require a future distinct acquisition API rather than inference from call failure. The ledger and resulting graph are private kernel machinery, not a plugin-facing plan or second declaration format. +After every facet has registered, the host generates its outgoing catalogue from RPC-capable provisions, obtains catalogues from its connections, resolves requirements to local or connected provisions, rejects missing providers, duplicate offers or singleton owners, singleton/keyed mismatches, invalid dependency cycles, and invalid RPC service implementations, then records consumer-to-provider edges for lifecycle ordering. `use()` and `observe()` declare hard requirements; optional dependencies require a future distinct acquisition API rather than inference from call failure. The ledger and resulting graph are private kernel machinery, not a facet-facing plan or second declaration format. -Only dependencies acquired through `env.use()` or `env.observe()` belong to this lifecycle graph. Importing another plugin's live implementation bypasses ownership and is unsupported. The module loader separately owns the ordinary source import graph. Complete-generation reload avoids needing that module graph for partial invalidation; see [`plugin-reloading.md`](plugin-reloading.md). +Only dependencies acquired through `env.use()` or `env.observe()` belong to this lifecycle graph. Importing another extension's live implementation bypasses ownership and is unsupported. The module loader separately owns the ordinary source import graph. Reload therefore needs both loaded-source ownership and the generated service graph; see [Reloading facets](#reloading-facets). The models service — the authority behind the model picker and thinking-level control — exercises methods, replicated state, and multiple consumers. @@ -257,15 +179,17 @@ export interface Models { export const Models = defineService("pi.models"); ``` -Everything transported in a remote contract is strict JSON: arguments, results, replicated state, and events. Business-level absence uses JSON `null`, never `undefined`. An unhydrated `ReplicatedState.value === undefined` is local control-plane readiness, not a transported state value. `Context` is control-plane data in a declared position; the proxy strips it and it is never serialized. +Everything transported in a remote contract is strict JSON: arguments, results, and replicated state. Business-level absence uses JSON `null`, never `undefined`. An unhydrated `ReplicatedState.value === undefined` is local control-plane readiness, not a transported state value. `Context` is control-plane data in a declared position; the proxy strips it and it is never serialized. ### Session facet +The snippets below use the facet shape but compress application details. + ```ts -export const providersBuiltin = definePlugin({ +export const providersBuiltinSessionFacet = defineFacet({ id: "@pi/providers-builtin", - session(env) { + setup(env) { const providers = new ProviderRegistry(); // process-local, non-JSON const state = env.replicatedState(initialModelsState()); @@ -310,19 +234,20 @@ export const providersBuiltin = definePlugin({ ### TUI facet +This shows the generic command-service pattern. + ```ts -export const modelSelection = definePlugin({ +export const modelSelectionTuiFacet = defineFacet({ id: "@pi/model-selection", - tui: { - session(env) { - const models = env.use(Models); - const tui = env.use(Tui); + setup(env) { + const models = env.use(Models); + const tui = env.use(Tui); - tui.commands.register("models.select", async (context) => { - const current = models.state.value; - if (current === undefined) return; - const selected = await tui.select( + tui.commands.register("models.select", async (context) => { + const current = models.state.value; + if (current === undefined) return; + const selected = await tui.select( "Models", current.catalog.availableModels.map((model) => ({ label: model.name, @@ -330,11 +255,10 @@ export const modelSelection = definePlugin({ })), { signal: context.abortSignal }, ); - if (selected !== undefined) await models.select(selected, context); - }); - tui.commands.register("models.cycle-thinking", (context) => models.cycleThinking(context)); - env.own(models.state.subscribe((next) => renderModelSelector(next))); - }, + if (selected !== undefined) await models.select(selected, context); + }); + tui.commands.register("models.cycle-thinking", (context) => models.cycleThinking(context)); + env.own(models.state.subscribe((next) => renderModelSelector(next))); }, }); ``` @@ -343,7 +267,7 @@ The TUI facet has no credentials, registry, or refresh logic: it calls a typed l ### Service semantics -A service has **one owner and many consumers**. In singleton mode, `providersBuiltin` provides `Models` and both model-selection commands consume it. In multi-instance mode, one owner may add instances `A` and `B`, and every observer sees the same two instances. +A service has **one owner and many consumers**. In singleton mode, `providersBuiltinSessionFacet` provides `Models` and both model-selection commands consume it. In multi-instance mode, one owner may add instances `A` and `B`, and every observer sees the same two instances. `use()` behaves differently by locality: @@ -352,7 +276,7 @@ A service has **one owner and many consumers**. In singleton mode, `providersBui Multi-instance services use `provideMany()` and `observe()`. The service is empty until its setup-owned `ServiceInstances` handle calls `add()`; observing it never creates an instance. `instances.add(key, implementation)` returns an idempotent close function, and the key must be unique among that service's live instances. `observe(service, handler)` reconciles a snapshot of current instances and then ordered additions, replacements, and removals. After an instance's initial state members hydrate, the host starts one handler task with a fresh `Context`. Closing the instance aborts that context, rejects new calls, and lets already-admitted calls return. Cancellation from the instance context is normal task cleanup; other handler failures follow host failure policy. Reusing a closed key creates a new host-owned generation, so stale proxies cannot address the replacement. -An added instance member has structural identity `(service, key, generation, member)`. Its `ReplicatedState` members therefore need no independent IDs. The instance directory is control-plane metadata, not a plugin-visible `ReplicatedState` containing proxies. Switching sessions aborts all observed instance tasks before hydrating the selected session's current instances. +An added instance member has structural identity `(service, key, generation, member)`. Its `ReplicatedState` members therefore need no independent IDs. The instance directory is control-plane metadata, not a facet-visible `ReplicatedState` containing proxies. Switching sessions aborts all observed instance tasks before hydrating the selected session's current instances. Every facet uses the same unqualified `env.use()` and `env.observe()` operations. A presentation host combines its local services with connected server and selected-Session services, then routes each token internally. Provider facets resolve services from the same host graph. Transport binding and routing remain host infrastructure rather than facet API. @@ -365,47 +289,25 @@ This is the most important boundary in the design. ```ts interface ScopedSessionData { readonly metadata: SessionMetadata; - getEntry(id: string, context: Context): Promise; - getStats(context: Context): Promise; getValue(address: Value, context: Context): Promise | undefined>; - scanValues(prefix: Value, context: Context): Promise[]>; - readList(address: ValueList, options: ListReadOptions | undefined, - context: Context): Promise[]>; - setValue(address: Value, next: NoInfer, context: Context): Promise; - deleteValue(address: Value, context: Context): Promise; - appendList(address: ValueList, element: NoInfer, context: Context): Promise; - deleteList(address: ValueList, context: Context): Promise; - getName(context: Context): Promise; - setName(name: string | undefined, context: Context): Promise; - getLabel(targetId: string, context: Context): Promise; - setLabel(targetId: string, label: string | undefined, context: Context): Promise; - findEntries(query: EntryQuery | undefined, context: Context): Promise; - findEntry(query: EntryQuery | undefined, context: Context): Promise; + setValue(address: Value, value: T, context: Context): Promise; } -interface AgentPluginScope { - readonly identity: SessionIdentity; // sessionId + workspaceId - lane(name: string, context: Context): Promise; +interface AgentFacetScope { + readonly identity: SessionIdentity; readonly session: ScopedSessionData; readonly hooks: ScopedHooks; - readonly events: ScopedEvents; + lane(name: string, context: Context): Promise; } -const Agent = defineService("pi.local.agent"); -const Providers = defineService("pi.local.providers"); -const Tools = defineService("pi.local.tools"); - -interface ClientLifecycle { - onAttach(callback: (clientId: string) => void): void; - onDetach(callback: (clientId: string) => void): void; -} - -const Clients = defineService("pi.local.clients"); +const Agent = defineService("pi.local.agent", { rpc: false }); +const Providers = defineService("pi.local.providers", { rpc: false }); +const Tools = defineService("pi.local.tools", { rpc: false }); ``` -"Local" and "unrestricted" are separate decisions. The scope narrows authority for lifecycle and composition — hooks and event subscriptions registered through it are automatically owned by the plugin and disposed with it. `AgentLanePluginView` exposes Branch methods directly alongside agent operations. `ScopedSessionData` exposes purpose-bounded global value/list/name/label/query operations. The host keeps the unrestricted concrete instances and reserves: `AgentHarness.close()` and `Session.close()`; raw `Session.mutate()`, `beginMutation()`, and `SessionMutator` (unless a narrowly trusted durability plugin explicitly owns them); `idGenerator` and backend/storage objects; Branch creation; whole-registry setters such as `setTools()`; unscoped hook/event registration; transport exposure and remote-reference registration. This is a composition and lifecycle boundary, not a security sandbox: session facets are trusted code in the authoritative process. The manifest may explicitly grant broader local capability, but built-ins receive no implicit bypass. +"Local" and "unrestricted" are separate decisions. The scope narrows authority for lifecycle and composition — hooks and event subscriptions registered through it are automatically owned by the facet and disposed with it. `AgentLaneFacetView` exposes Branch methods directly alongside agent operations. `ScopedSessionData` exposes purpose-bounded durable operations. The host keeps the unrestricted concrete instances and reserves: `AgentHarness.close()` and `Session.close()`; raw `Session.mutate()`, `beginMutation()`, and `SessionMutator` (unless a narrowly trusted durability extension explicitly owns them); `idGenerator` and backend/storage objects; Branch creation; whole-registry setters such as `setTools()`; unscoped hook/event registration; transport exposure and remote-reference registration. This is a composition and lifecycle boundary, not a security sandbox: session facets are trusted code in the authoritative process. A future extension policy may explicitly grant broader local capability, but built-ins should receive no implicit bypass. -**Presentation facets hold none of this.** A TUI or web facet never receives the raw Harness, Session, tree, tool registry, hooks, or credentials. It uses host-local presentation services plus the semantic services, replicated state, and events deliberately exposed by a session or server facet. +**Presentation facets hold none of this.** A TUI or web facet never receives the raw Harness, Session, tree, tool registry, hooks, or credentials. It uses host-local presentation services plus the semantic services and replicated state deliberately exposed by a Session or server facet. ```ts interface RemoteServiceInstance { @@ -421,7 +323,7 @@ interface FacetEnvironment extends FacetLifecycle { ): () => void; provide(service: Service, implementation: T): void; provideMany(service: Service): ServiceInstances; - replicatedState(initial: T): MutableReplicatedState; + replicatedState(initial: T): MutableReplicatedState; } type AttachmentState = { status: "detached" } | { status: "attaching" | "attached" | "degraded"; sessionId: string }; @@ -439,38 +341,41 @@ interface TuiModal { } interface TuiHost { - readonly connection: ReplicatedState; readonly attachment: ReplicatedState; readonly commands: CommandContributions; - readonly keybindings: KeybindingContributions; readonly toolRenderers: ToolRendererContributions; - readonly slots: SlotContributions; acquireModal(signal: AbortSignal): Promise; select(title: string, items: SelectItem[], options: { signal: AbortSignal }): Promise; - // overlays and focus facilities } -const Tui = defineService("pi.local.tui"); +const Tui = defineService("pi.local.tui", { rpc: false }); ``` `acquireModal()` waits in one presentation-owned queue and holds the modal slot across a multi-step interaction. Its signal removes a queued request or dismisses an active one, and `close()` is idempotent. `select()` is the one-step acquire/select/close convenience. Both return selected values directly, so feature code never recovers identity from a display label. -The TUI loads all of its facets into one generation. Its host routes `env.use(SessionDirectory)` to the connected server and `env.use(Models)` to the selected Session. While detached, Session calls fail with `not_attached` and replicated state has no value. Connection and attachment health are host-local services because they describe presentation control state. A future web host similarly binds local services for routes, views, and DOM dialogs. Its server and Session facets still use unqualified service operations. +The TUI loads all of its facets into one generation. Its host routes `env.use(SessionDirectory)` to the connected server and `env.use(Models)` to the selected Session. While detached, Session calls fail with `session_not_attached` and replicated state has no value. Connection and attachment health are host-local services because they describe presentation control state. A future web host similarly binds local services for routes, views, and DOM dialogs. Its server and Session facets still use unqualified service operations. -Concretely, the minimum chat slice exposes `prompt(request, context)` returning `{ accepted, operationId, error }`, plus `requestAbort(operationId, context)`, and implements it with direct local lane capabilities. The experimental `Chat` contract also predeclares the later steer, follow-up, next-run, queue cancellation, resume, compaction, and navigation presentation operations; those members throw `ServiceSliceNotImplemented` until their Harness adapter slices land: +The minimum `Chat` service exposes `prompt(request, context)` returning `{ accepted, operationId, error }`, plus `requestAbort(operationId, context)`. Its Session facet delegates through a process-local scoped Agent service. Additional queue, navigation, resume, and compaction operations should be added only with concrete presentation requirements. + +The scoped form is: ```ts -session(env) { - const lane = env.use(Agent).main; - env.provide(Chat, { - async prompt(request, context) { - return toPromptResponse(await lane.prompt(request.message, context)); - }, - async requestAbort(operationId, context) { - await lane.requestAbort(operationId, context); - }, - }); -} +export const chatSessionFacet = defineFacet({ + id: "@pi/chat", + setup(env) { + const agent = env.use(Agent); + env.provide(Chat, { + async prompt(request, context) { + const lane = await agent.lane("main", context); + return toPromptResponse(await lane.prompt(request.message, context)); + }, + async requestAbort(operationId, context) { + const lane = await agent.lane("main", context); + await lane.requestAbort(operationId, context); + }, + }); + }, +}); ``` Its TUI facet consumes `Chat` through `env.use()` exactly as the model picker consumes `Models`. Neither reveals the Harness object behind them; there is no universal remote Harness for arbitrary plugins. `rpc.md` may still define generic Harness proxies for other trusted integrations (an IDE bridge, an orchestrator) — deliberate, separate exposures, not the plugin boundary. @@ -480,7 +385,7 @@ Its TUI facet consumes `Chat` through `env.use()` exactly as the model picker co Not every dependency should be remotely reachable. A **local service** is a token confined to its providing process. It may hold functions, native objects, credentials, or filesystem handles; remote `use()` cannot resolve it, and local services are never discoverable remotely. The pattern for sensitive state is a local full service plus a narrow remote facade: ```ts -const Credentials = defineLocalService("credentials"); // get/set provider secrets +const Credentials = defineService("credentials", { rpc: false }); // get/set provider secrets interface Accounts { readonly state: ReplicatedState<{ providers: Array<{ provider: string; configured: boolean }> }>; @@ -489,7 +394,7 @@ interface Accounts { const Accounts = defineService("pi.accounts"); ``` -The auth plugin's session facet uses `Credentials` directly; presentations see provider IDs and `configured` booleans — never secrets. If some settings must not be remotely writable, split them the same way; do not rely on presentation-side convention. +The auth extension's Session facet uses `Credentials` directly; presentations see provider IDs and `configured` booleans — never secrets. If some settings must not be remotely writable, split them the same way; do not rely on presentation-side convention. ## Replicated state: `ReplicatedState` @@ -516,18 +421,18 @@ Required behavior: 1. The providing host owns one initialized authoritative value; remote consumers call methods rather than writing the replica. 2. A cold remote replica has no value. Its `.value` is `undefined`, and `subscribe()` registers the listener without invoking it. This `undefined` is local readiness state and never crosses the wire. 3. **Hydration** installs a complete snapshot atomically before updates flow. Subscribing before hydration is valid, and updates emitted concurrently with the snapshot are buffered, so the listener observes snapshot then updates with no gap. -4. Once hydrated, `.value` is synchronously readable and `subscribe()` immediately reports the current value, then future updates. The first snapshot callback uses a fresh **hydration context** parented to the subscription; an immediate callback for an already-present value uses a fresh local delivery context because the write that produced it may be long settled. +4. Once hydrated, `.value` is synchronously readable and `subscribe()` immediately reports the current value, then future updates. Snapshot hydration uses a fresh delivery context parented to the subscription; later updates reconstruct fresh delivery contexts from source trace metadata. 5. State values are borrowed immutable JSON. The state runtime does not defensively clone reads, writes, snapshots, or listener deliveries. Callers transfer ownership to `set()` and must not mutate or retain values returned by `.value` or passed to listeners; copy explicitly when ownership is required. Process and transport serialization may naturally produce a detached value, but callers must not depend on object identity or detachment. -6. Disconnect may retain the last value as stale display data alongside connection or attachment state. Reconnecting to the same provider replaces it with a fresh snapshot. Reloading a singleton provider installs its complete replacement snapshot in the existing member facade. Switching routes to a different provider clears readiness and `.value` becomes `undefined` until that provider hydrates; route-bound presentation resources are disposed as part of the switch. -7. `set(value, context)` carries source trace metadata; each live delivery invokes listeners with a reconstructed delivery `Context`. Background updates use an intentional background or lifecycle context, never a retained caller context. +6. Disconnect, provider withdrawal, and route switching clear readiness, so `.value` becomes `undefined`. Reconnect or singleton replacement installs a complete fresh snapshot in the existing member facade before later updates flow. A presentation that wants stale display data must retain it separately alongside connection or attachment health. +7. `set(value, context)` passes its context to local source listeners and publishes source trace metadata. Remote delivery reconstructs a fresh local `Context`; it never retains the source context object. Anything a consumer must recover after reconnect is exposed as replicated state or pulled through a remote method. Replicated state is latest-value replication, not by itself durable session storage; the providing facet must reconstruct its authoritative value after a worker restart. -Prefer several coarse independent cells over one giant value or a universal patch language, so a catalogue refresh does not retransmit unrelated configuration. High-frequency data such as transcript streaming needs a future snapshot-and-delta design rather than overloading `ReplicatedState`. Revision metadata, gap recovery, unchanged-value suppression, and demand-driven subscription belong to that future protocol, not individual plugin authors. +Prefer several coarse independent cells over one giant value or a universal patch language, so a catalogue refresh does not retransmit unrelated configuration. High-frequency data such as transcript streaming needs a future snapshot-and-delta design rather than overloading `ReplicatedState`. Revision metadata, gap recovery, unchanged-value suppression, and demand-driven subscription belong to that future protocol, not individual facet authors. ## Contribution registries: many contributors, one result -Services fit one owner, many consumers. Providers and tools invert that: **many plugins contribute to one host-owned result**. A mutable global registry would make composition order-dependent and removal impossible. A contribution registry instead replays ordered contributions over a fresh draft: +Services fit one owner, many consumers. Providers and tools invert that: **many extensions contribute to one host-owned result**. A mutable global registry would make composition order-dependent and removal impossible. A contribution registry instead replays ordered contributions over a fresh draft: ```text fresh ProviderDraft @@ -538,7 +443,7 @@ fresh ProviderDraft → validated ProviderState ``` -Removing a plugin removes its contribution and rebuilds; nothing runs an inverse mutation. Tools follow the same model, including wrapping: +Removing an extension removes its contribution and rebuilds; nothing runs an inverse mutation. Tools follow the same model, including wrapping: ```ts sessionContext.tools.add((draft) => { @@ -550,15 +455,11 @@ sessionContext.tools.add((draft) => { }); ``` -Ordered wrappers compose deterministically — `telemetry(permission(sandbox(coreBash)))` — and if the permission plugin disappears, rebuilding yields `telemetry(sandbox(coreBash))`. Only the host finalizes the draft and applies the complete registry to the Harness; plugins never call `setTools()`. Contributions configure rebuilt behavior; hooks intercept live operations — separate mechanisms. +Ordered wrappers compose deterministically — `telemetry(permission(sandbox(coreBash)))` — and if the permission extension disappears, rebuilding yields `telemetry(sandbox(coreBash))`. Only the host finalizes the draft and applies the complete registry to the Harness; facets never call `setTools()`. Contributions configure rebuilt behavior; hooks intercept live operations — separate mechanisms. -## Context, cancellation, and telemetry for plugin authors +## Context, cancellation, and telemetry for facet authors -`rpc.md` and `telemetry.md` own the mechanisms. What a plugin author needs to know: - -- Every remote method receives a `Context` in its declared position. The proxy strips it from the JSON arguments and uses it to parent `rpc.client`, inject the trace carrier, and map `context.abortSignal` to request cancellation. -- The server never deserializes the client's context. It constructs a fresh one: a request-local abort signal, a telemetry parent extracted from the trace carrier, and server-created typed values such as the authenticated client identity. Clients cannot smuggle context values across. -- No receiver-level defaults: shared service objects never retain a caller's context. +Every remote method receives a fresh local `Context` in its declared position. The proxy strips the caller's context from JSON arguments and maps `context.abortSignal` to cancellation of that one request. The receiving endpoint constructs a request-local abort signal; it never deserializes the sender's `Context` or arbitrary typed values. Shared service objects must not retain a caller's context. The model refresh shows the whole author-visible surface: @@ -570,7 +471,7 @@ await uiTelemetry.startSpan({ name: "ui.models.refresh" }, async (span) => { }); ``` -The implementation may open its own span for lower work, producing the distributed trace: +RPC telemetry composes with application spans as: ```text ui.models.refresh @@ -591,7 +492,9 @@ A transport disconnect performs only the first for active requests and closes th ## Service-owned jobs -Long-running work should return a capability rather than one method that blocks indefinitely: +Private returned references are outside the initial service contract. Prefer `provideMany()` for discoverable live instances. Add caller-private references only after a concrete feature establishes their ownership and collection requirements. + +A possible long-running job contract is: ```ts interface IndexJob { @@ -605,19 +508,19 @@ An `IndexService.start(root, context)` returning an `IndexJob` validates the roo ## The server: directory, management, and routing -The server host does two jobs. It **owns server-wide services**—listing, creating, deleting, and attaching to sessions—and it **routes session traffic** between attached presentations and the session workers it manages. Routing is host infrastructure that plugin code does not implement. +The server host does two jobs. It **owns server-wide services**—listing, creating, deleting, and attaching to sessions—and it **routes session traffic** between attached presentations and the session workers it manages. Routing is host infrastructure that facet code does not implement. A server facet is shared by every session and presentation connected to the server. It should be used only for inherently server-wide features. Per-session feature data belongs in session facets. ### Server host services ```ts -interface FleetPluginScope { +interface FleetFacetScope { readonly managed: ManagedSessionsView; // sessions managed by this server readonly attachments: AttachmentsView; // bind/unbind a client's selected session } -const Fleet = defineService("pi.local.fleet"); +const Fleet = defineService("pi.local.fleet", { rpc: false }); ``` The raw `SessionRepo`, storage handles, unrestricted process-kill authority, routing map, and routing machinery stay with the server application: @@ -629,8 +532,6 @@ interface ManagedSessionRecord { workspaceId: string; ownerId: string; cwd: string; // ownerId and cwd never leave the server - status: "starting" | "active" | "idle" | "closed" | "unreachable"; - updatedAt: string; } type ManagedSessionChange = { type: "created" | "changed" | "deleted"; record: ManagedSessionRecord }; @@ -651,9 +552,6 @@ The directory is read; management mutates and selects. Both are presentation-saf export interface SessionRecordSummary { sessionId: string; title: string; - workspaceId: string; - status: ManagedSessionRecord["status"]; - updatedAt: string; } export interface SessionDirectory { @@ -676,39 +574,44 @@ export const SessionManagement = defineService("pi.session-ma ```ts // server.ts -export function sessionDirectoryServerFacet(env: FacetEnvironment) { - const { managed, attachments } = env.use(Fleet); - const state = env.replicatedState({ revision: 0, sessions: [] as SessionRecordSummary[] }); +export const sessionDirectoryServerFacet = defineFacet({ + id: "@pi/session-directory", + setup(env) { + const { managed, attachments } = env.use(Fleet); + const state = env.replicatedState({ revision: 0, sessions: [] as SessionRecordSummary[] }); - function publish(_change: ManagedSessionChange, context: Context) { - state.set({ revision: state.value.revision + 1, sessions: managed.snapshot().map(toSummary) }, context); - } + function publish(_change: ManagedSessionChange, context: Context) { + state.set({ revision: state.value.revision + 1, sessions: managed.snapshot().map(toSummary) }, context); + } - env.own(managed.onChanged(publish)); - env.onActivate(() => - state.set({ revision: 1, sessions: managed.snapshot().map(toSummary) }, BACKGROUND_CONTEXT), - ); + env.own(managed.onChanged(publish)); + env.onActivate(() => + state.set({ revision: 1, sessions: managed.snapshot().map(toSummary) }, BACKGROUND_CONTEXT), + ); - env.provide(SessionDirectory, { state }); - env.provide(SessionManagement, { - async create(options, context) { - const client = requireClientIdentity(context); - return toSummary(await managed.create({ title: options.title, workspaceId: client.workspaceId }, context)); - }, - async remove(sessionId, context) { - authorizeTarget(requireClientIdentity(context), managed.snapshot(), sessionId); - await managed.remove(sessionId, context); - }, - async attach(sessionId, context) { - const client = requireClientIdentity(context); - authorizeTarget(client, managed.snapshot(), sessionId); - await attachments.bind(client.clientId, sessionId, context); - }, - async detach(context) { - await attachments.unbind(requireClientIdentity(context).clientId, context); - }, - }); -} + env.provide(SessionDirectory, { state }); + env.provide(SessionManagement, { + async create(options, context) { + const client = requireClientIdentity(context); + return toSummary( + await managed.create({ title: options.title, workspaceId: client.workspaceId }, context), + ); + }, + async remove(sessionId, context) { + authorizeTarget(requireClientIdentity(context), managed.snapshot(), sessionId); + await managed.remove(sessionId, context); + }, + async attach(sessionId, context) { + const client = requireClientIdentity(context); + authorizeTarget(client, managed.snapshot(), sessionId); + await attachments.bind(client.clientId, sessionId, context); + }, + async detach(context) { + await attachments.unbind(requireClientIdentity(context).clientId, context); + }, + }); + }, +}); function authorizeTarget(client: ClientIdentity, records: ManagedSessionRecord[], sessionId: string) { const record = records.find((candidate) => candidate.sessionId === sessionId); @@ -717,8 +620,8 @@ function authorizeTarget(client: ClientIdentity, records: ManagedSessionRecord[] } } -function toSummary({ sessionId, title, workspaceId, status, updatedAt }: ManagedSessionRecord): SessionRecordSummary { - return { sessionId, title, workspaceId, status, updatedAt }; +function toSummary({ sessionId, title }: ManagedSessionRecord): SessionRecordSummary { + return { sessionId, title }; } ``` @@ -728,28 +631,31 @@ Every call is authorized against the client identity that transport policy insta ```ts // tui.ts -export function sessionPickerTuiFacet(env: FacetEnvironment) { - const directory = env.use(SessionDirectory); - const management = env.use(SessionManagement); - const tui = env.use(Tui); +export const sessionPickerTuiFacet = defineFacet({ + id: "@pi/session-picker", + setup(env) { + const directory = env.use(SessionDirectory); + const management = env.use(SessionManagement); + const tui = env.use(Tui); - tui.commands.register("sessions.switch", async (context) => { - const current = directory.state.value; - const attachment = tui.attachment.value; - if (current === undefined || attachment === undefined) return; - const selected = await tui.select( - "Sessions", - current.sessions.map((session) => ({ - label: pickerLabel(session, attachment), - value: session.sessionId, - })), - { signal: context.abortSignal }, - ); - if (selected !== undefined) await management.attach(selected, context); - }); + tui.commands.register("sessions.switch", async (context) => { + const current = directory.state.value; + const attachment = tui.attachment.value; + if (current === undefined || attachment === undefined) return; + const selected = await tui.select( + "Sessions", + current.sessions.map((session) => ({ + label: pickerLabel(session, attachment), + value: session.sessionId, + })), + { signal: context.abortSignal }, + ); + if (selected !== undefined) await management.attach(selected, context); + }); - env.own(directory.state.subscribe((next) => renderSessionList(next))); -} + env.own(directory.state.subscribe((next) => renderSessionList(next))); + }, +}); ``` The TUI facet consumes a service provided by the one connected server. There is no session facet in this plugin because sessions do not own discovery or attachment. @@ -759,7 +665,7 @@ The TUI facet consumes a service provided by the one connected server. There is `attach(sessionId)` selects the session for this presentation connection: 1. the server authorizes the client for one of its managed sessions; -2. it closes the client's previous session-scoped requests, subscriptions, references, and observed instance tasks; +2. it closes the client's previous session-scoped requests, subscriptions, and observed instance tasks; 3. it binds the presentation host's Session services to the selected Session worker; 4. the Session worker hydrates singleton state and current keyed instances from complete fresh snapshots; attachment state becomes `attached`. @@ -774,15 +680,15 @@ S1: rpc.server chat.prompt — fresh local Context, validated JSON args → lane response returns S1 → server → TUI A ``` -Aborting the TUI request sends cancellation through the server to S1, aborting the session-side request controller. Trace carriers pass through routing; `Context` is reconstructed at the service endpoint. +Aborting the TUI request sends cancellation through the server to S1, aborting the session-side request controller. `Context` and trace metadata are reconstructed at the service endpoint. ### Routing is host infrastructure -The server routes session traffic contract-agnostically. It parses protocol envelopes—frame kind, request ID, service ID, optional instance key/generation, and selected session—but not plugin business payloads. Validation happens at service endpoints, so the server can route a session plugin service without loading that session facet. +The server routes session traffic contract-agnostically. It parses protocol envelopes—frame kind, request ID, service ID, optional instance key/generation, and selected session—but not service business payloads. Validation happens at service endpoints, so the server can route a Session service without loading that session facet. -The server stamps routed calls with the client identity it authenticated. The session worker keys connection-owned requests by that identity, preventing collisions and cross-client cancellation. No server facet participates in routing or re-provides session services. +The server stamps routed calls with its authenticated client identity. The Session worker keys connection-owned requests per presentation route, preventing request-ID collisions and cross-client cancellation. No server facet participates in routing or re-provides session services. -## Session-owned deferred interactions: the question plugin +## Session-owned deferred interactions: the question extension Some session-side work must ask users for a decision. The existing `examples/extensions/question.ts` shows the experience: the model calls a `question` tool, a user selects an option or types an answer, and the tool returns that answer with a compact rendering. @@ -841,55 +747,64 @@ function questionResult(request: QuestionRequest, answer: string | null, wasCust ```ts // session.ts -export function questionSessionFacet(env: FacetEnvironment) { - const dialogs = env.provideMany(QuestionDialogs); - const tools = env.use(Tools); +export const questionSessionFacet = defineFacet({ + id: "@pi/question", + setup(env) { + const dialogs = env.provideMany(QuestionDialogs); + const tools = env.use(Tools); - tools.add((draft) => { - draft.set("question", { - label: "Question", - description: "Ask users a question and wait for an answer.", - executionMode: "sequential", - replay: "safe", - parameters: QuestionParamsSchema, + tools.add((draft) => { + draft.set("question", { + label: "Question", + description: "Ask users a question and wait for an answer.", + executionMode: "sequential", + replay: "safe", + parameters: QuestionParamsSchema, - async execute(_toolCallId, params, _onUpdate, _toolContext, invocation, context) { - if (params.options.length === 0) return questionResult(params, null, false, "No options provided"); - - const memoName = "pi.question.answer"; - let response = (await invocation.getMemo(memoName)) as QuestionResponse | undefined; - - if (response === undefined) { - const completion = Promise.withResolvers(); - const request = env.replicatedState(params); - const close = dialogs.add(invocation.invocationId, { - request, - async submitAnswer(candidate, _answerContext) { - if (candidate.outcome === "selected" && params.options[candidate.index] === undefined) { - throw new Error("Question response selected an invalid option"); - } - const committed = invocation.memoOnce(memoName, candidate); - completion.resolve(committed); - await committed; - }, - }); - - try { - response = await awaitAbortable(completion.promise, context.abortSignal); - } finally { - close(); + async execute(_toolCallId, params, _onUpdate, _toolContext, invocation, context) { + if (params.options.length === 0) { + return questionResult(params, null, false, "No options provided"); } - } - if (response.outcome === "cancelled") return questionResult(params, null, false, "User cancelled the question"); - if (response.outcome === "custom") return questionResult(params, response.answer, true, `User wrote: ${response.answer}`); - const selected = params.options[response.index]; - if (selected === undefined) throw new Error("Question response selected an invalid option"); - return questionResult(params, selected.label, false, `User selected: ${response.index + 1}. ${selected.label}`); - }, + const memoName = "pi.question.answer"; + let response = (await invocation.getMemo(memoName)) as QuestionResponse | undefined; + + if (response === undefined) { + const completion = Promise.withResolvers(); + const request = env.replicatedState(params); + const close = dialogs.add(invocation.invocationId, { + request, + async submitAnswer(candidate, _answerContext) { + if (candidate.outcome === "selected" && params.options[candidate.index] === undefined) { + throw new Error("Question response selected an invalid option"); + } + const committed = invocation.memoOnce(memoName, candidate); + completion.resolve(committed); + await committed; + }, + }); + + try { + response = await awaitAbortable(completion.promise, context.abortSignal); + } finally { + close(); + } + } + + if (response.outcome === "cancelled") { + return questionResult(params, null, false, "User cancelled the question"); + } + if (response.outcome === "custom") { + return questionResult(params, response.answer, true, `User wrote: ${response.answer}`); + } + const selected = params.options[response.index]; + if (selected === undefined) throw new Error("Question response selected an invalid option"); + return questionResult(params, selected.label, false, `User selected: ${response.index + 1}. ${selected.label}`); + }, + }); }); - }); -} + }, +}); ``` `dialogs.add()` installs the instance before `execute()` waits. The returned close function is the single normal, cancellation, and error cleanup path. Concurrent submissions call `memoOnce()`, whose atomic first-writer rule prevents overwrite and returns the same durable winner. `completion.resolve(committed)` makes the local wait follow that durable operation: success resumes the tool, while failure rejects it and runs the same cleanup instead of leaving it suspended. Each service call also awaits its own `committed` promise, so it cannot report success before durability or leave an ignored rejection. Calls through a closed instance or an old generation fail as stale service calls. @@ -902,46 +817,49 @@ type QuestionChoice = | { outcome: "selected"; index: number } | { outcome: "custom" }; -export function questionTuiFacet(env: FacetEnvironment) { - const tui = env.use(Tui); - env.own( - env.observe(QuestionDialogs, async (dialog, context) => { - const request = dialog.service.request.value; - if (request === undefined) throw new Error("Question dialog was observed before hydration"); +export const questionTuiFacet = defineFacet({ + id: "@pi/question", + setup(env) { + const tui = env.use(Tui); + env.own( + env.observe(QuestionDialogs, async (dialog, context) => { + const request = dialog.service.request.value; + if (request === undefined) throw new Error("Question dialog was observed before hydration"); - const modal = await tui.acquireModal(context.abortSignal); - try { - const choice = await modal.select( - request.question, - [ - ...request.options.map((option, index) => ({ - label: option.label, - ...(option.description === null ? {} : { description: option.description }), - value: { outcome: "selected" as const, index }, - })), - { label: "Write a custom answer", value: { outcome: "custom" as const } }, - ], - ); + const modal = await tui.acquireModal(context.abortSignal); + try { + const choice = await modal.select( + request.question, + [ + ...request.options.map((option, index) => ({ + label: option.label, + ...(option.description === null ? {} : { description: option.description }), + value: { outcome: "selected" as const, index }, + })), + { label: "Write a custom answer", value: { outcome: "custom" as const } }, + ], + ); - let response: QuestionResponse; - if (choice === undefined) { - response = { outcome: "cancelled" }; - } else if (choice.outcome === "selected") { - response = choice; - } else { - const answer = await modal.input(request.question); - response = answer === undefined ? { outcome: "cancelled" } : { outcome: "custom", answer }; + let response: QuestionResponse; + if (choice === undefined) { + response = { outcome: "cancelled" }; + } else if (choice.outcome === "selected") { + response = choice; + } else { + const answer = await modal.input(request.question); + response = answer === undefined ? { outcome: "cancelled" } : { outcome: "custom", answer }; + } + + await dialog.service.submitAnswer(response, context); + } finally { + modal.close(); } + }), + ); - await dialog.service.submitAnswer(response, context); - } finally { - modal.close(); - } - }), - ); - - tui.toolRenderers.add("question", questionRenderer); -} + tui.toolRenderers.add("question", questionRenderer); + }, +}); ``` `observe()` runs one abortable task per open instance, including instances present in the hydration snapshot. Three concurrent tool invocations therefore produce three tasks keyed by their invocation IDs. The TUI modal queue displays them one at a time; a web host may render all three. Closing one instance aborts only its task in every presentation. @@ -954,70 +872,111 @@ The service instance is live process state; the invocation memo is the replay re If the worker dies before the answer commit, the old instance and promise disappear. Safe replay reads no answer and adds the same logical key with a new generation. If it dies after the commit, replay reads the answer and returns without adding an instance. A client cannot answer while the worker is absent; calls through the old generation fail instead of locating an invocation by bare ID. -The memo has the existing invocation lifetime. Staging the tool result as `outcome_ready` atomically deletes it; cancellation and external finalization use the same cleanup. The question request is not copied into another memo because the Harness already persisted the effective tool arguments. Source reload follows this same worker-replacement path; see [`plugin-reloading.md`](plugin-reloading.md). +The memo has the existing invocation lifetime. Staging the tool result as `outcome_ready` atomically deletes it; cancellation and external finalization use the same cleanup. The question request is not copied into another memo because the Harness already persisted the effective tool arguments. Source reload uses this same durable worker-reconstruction path. -## Lifecycle, disposal, and reload +## Lifecycle and disposal -Every facet-owned resource — singleton registrations, still-open service instances, contributions, state subscriptions, watchers, timers, in-flight RPCs, subprocesses, overlays — is owned by the facet environment and disposed when that environment closes, in reverse dependency order. Host capabilities enforce this automatically for hooks and event subscriptions; `onDeactivate` handles the rest. Contribution registries rebuild from the remaining ordered contributions instead of asking a plugin to reverse shared mutations. +A facet environment owns service provisions, `provideMany()` instances, observations, and resources explicitly registered through `own()`. Facets must register state subscriptions, watchers, timers, subprocesses, overlays, and other external resources themselves. The host deactivates consumers before providers and runs each facet's owned cleanups in reverse registration order. -Source reload replaces the affected facet environments while retaining unaffected consumers. The host stages each replacement against the existing dependency graph, disconnects its old provisions, deactivates the old facet, activates the replacement, and rebinds its provisions before unloading the old module generation. A singleton facade returned earlier by `use()` keeps its identity: local calls resolve the current implementation, and RPC consumers receive a complete replacement snapshot on the existing subscription. Reloads that change service requirements, provisions, modes, or manifest membership require graph reassembly before they can switch. +Already-admitted inbound RPC calls may continue while their providing facet deactivates. Withdrawing a provider rejects new calls. Code that requires stronger fencing needs an explicit lifecycle-owned controller. -Reload has no safety or rollback guarantee beyond ordinary facet shutdown and activation. Committed durable state survives, removed plugin data is retained, interrupted calls are not automatically replayed, and non-restartable process effects may fail. Hosts may temporarily run different source generations, so contracts remain forward compatible during convergence. See [`plugin-reloading.md`](plugin-reloading.md) for the complete design. +## Reloading facets + +Reload means replacing loaded facet source. It is not a transaction over durable Session state or external effects. + +### Shape-preserving provider replacement + +`FacetHost.reload()` replaces facets by `Facet.id` only when each replacement declares exactly the same service requirements, provisions, and singleton/keyed modes as the active facet. The tested sequence is: + +```text +load replacement facets +→ run setup and validate the unchanged service shape +→ withdraw replaced singleton provisions +→ deactivate old facets in reverse dependency order +→ activate replacements in dependency order +→ rebind local implementation slots +→ publish complete RPC singleton replacement snapshots +→ dispose the retired LoadedFacets generation +``` + +The loader disposal is deliberately outside `FacetHost.reload()`: the coordinator that loaded a module owns that module. It must dispose the old `LoadedFacets` only after the old active facets retire, and dispose a failed candidate generation if setup or validation fails. + +A singleton facade belongs to its consumer rather than a provider generation. Existing local proxies and captured local methods dispatch to the replacement implementation. Existing RPC proxies, captured methods, and replicated-state facades retain identity. During replacement they are unavailable: calls fail instead of queueing, and state becomes unhydrated until the complete replacement snapshot arrives. + +Reload has no rollback guarantee after old-facet deactivation begins. A failed replacement leaves affected services unavailable or degraded. Already-admitted calls are not automatically replayed or cancelled; callers must reconcile uncertain durable outcomes through authoritative state or stable operation IDs. + +### Shape changes and process replacement + +Changing requirements, provisions, modes, facet membership, or process authority is structural. It requires a newly assembled graph or an ordinary process restart; `FacetHost.reload()` intentionally rejects it. + +A reload coordinator lives outside the facet graph it replaces and follows these rules: + +1. Reload control stays outside the facet graph being replaced. +2. It chooses a desired source generation, then independently loads the affected server, Session, and presentation bundles. There is no aggregate cross-process extension object. +3. Shape-preserving facets use the existing host reload primitive. Structural changes build and validate a candidate graph before switching. Session-authority changes stop the old worker and release Session ownership before opening the replacement. +4. Hosts may converge at different times, so shared service contracts and durable records must tolerate temporary source-generation skew. +5. Failure is reported without pretending to roll back committed Session records, filesystem writes, subprocess effects, or hosts that already switched. + +`ReplicatedState` is a projection rather than storage. A replacement provider reconstructs authoritative state from durable Session records, configuration, or another owned source and publishes a complete snapshot. Keyed instances that must survive a worker restart likewise need durable application records and return as fresh live generations. Provider-local transport sequences and keyed generations may restart after rebinding and are never globally monotonic. + +Exactly one worker may own an open Session. Worker replacement therefore has a route gap: + +```text +old worker stops and releases Session ownership +→ selected Session remains logically selected but unavailable +→ replacement worker opens the Session and reconstructs services +→ server creates a fresh attachment binding +→ presentations hydrate fresh singleton and keyed snapshots +``` + +Reload never blindly retries an interrupted mutation. A request may have committed before its response was lost. Durable resumability belongs to Harness and application contracts, not facet cleanup. ## Connection loss, errors, and security -Disconnect behavior, from the plugin author's perspective: +Disconnect behavior, from the facet author's perspective: -- **A presentation disconnects.** Its server aborts the client's active requests, closes its observed instance tasks and other session-routed resources, and the selected session's `onClientDetach` fires. Session-owned work continues per application policy. An added question dialog remains Session-owned; the disconnected presentation loses its proxy and any in-flight `submitAnswer()` call fails. -- **A session worker disconnects or crashes.** Its server fails routed in-flight calls with `session_unavailable`, closes that worker's observed instance tasks, and updates the managed record's status. Attached presentations see `attachment.status === "degraded"` while the server connection stays healthy, so the picker still works and the user can attach elsewhere. +- **A presentation disconnects.** Its server aborts the client's active requests and closes its observed instance tasks and other session-routed resources. Session-owned work continues per application policy. An added question dialog remains Session-owned; the disconnected presentation loses its proxy and any in-flight `submitAnswer()` call fails. +- **A Session worker disconnects or crashes.** Its server fails routed in-flight calls and closes that worker's observed instance tasks. Attached presentations see `attachment.status === "degraded"` while the server connection stays healthy, so the directory still works and the user can attach elsewhere. - **A process loses its server connection.** Its connected server and Session services become unavailable. A Session worker loses server services and all attached presentations at once; unattended-Session policy decides whether it exits. -- Reconnect and reattach always hydrate from a fresh authoritative snapshot; prior remote references are invalid unless the exposure has explicitly session-stable identity. **Never blindly replay a mutation after an uncertain disconnect** — a replayed `select()` is harmless, a replayed `prompt()` is not. Reconnect, hydrate, and reconcile, or design the operation around a stable operation ID with explicit lookup semantics. +- Reconnect and reattach always hydrate from a fresh authoritative snapshot; prior keyed proxies and attachment-bound frames are invalid. **Never blindly replay a mutation after an uncertain disconnect** — a replayed `select()` is harmless, a replayed `prompt()` is not. Reconnect, hydrate, and reconcile, or design the operation around a stable operation ID with explicit lookup semantics. -Errors cross the wire as a JSON envelope `{ code, message, data? }` with stable codes. Expected service errors use stable codes or result values; unexpected exceptions become an internal error with safe metadata — no stacks or sensitive causes by default. Experimental scaffold members use `service_not_implemented` until their implementation slice lands. Cancellation, disconnect, unknown service/method, invalid arguments/results, stale references, `not_attached`, `not_authorized`, `unknown_session`, and `session_unavailable` need distinct codes. +Errors cross the wire as a JSON envelope `{ code, message }` with stable protocol and service codes. Unexpected exceptions become `internal_error` without exposing stacks. Authentication and application errors use registered stable codes. -Security rules every providing host (session and server alike) must enforce: +Boundary rules: - RPC-capable service IDs come from trusted loaded service tokens; the remote boundary accepts only implementation functions and branded replicated-state members, instance generations are host-owned, and `{ rpc: false }` services are never discoverable remotely; -- business arguments, results, state, and events are validated as JSON; protocol envelopes cannot be forged as ordinary values; -- clients cannot choose context position, server typed values, telemetry parents, or cancellation targets other than their own request IDs; -- credentials, prompts, completions, tool arguments/results, and filesystem contents are not exposed unless an explicit contract permits them; state snapshots contain only client-safe data; -- remote methods authorize server-side even when the TypeScript client surface hides them. +- business arguments, results, and state are validated as JSON; protocol envelopes cannot be forged as ordinary values; +- clients cannot choose context position, instance generations, selected-Session routing fields, or cancellation targets other than their own requests; and +- credentials, prompts, completions, tool arguments/results, and filesystem contents are not exposed unless an explicit contract permits them. -## The coding agent as a plugin manifest +## Host composition -The coding agent is one manifest of built-in and third-party plugins; each app host loads its facets through the same kernel in deterministic order. Server facets cover the directory/management services, authentication and client authorization, and worker spawn/stop policy and health. Session facets cover session creation/restoration and the scoped Harness facade; providers, model selection, and authentication; tools, wrappers, and permissions; prompt/steer/abort/compaction services; transcript persistence and snapshot projection; filesystem and subprocess effects; temporary service instances for deferred interactions; unattended-session policy; and server service consumption where a feature needs server-wide data. TUI facets cover the chat screen, transcript rendering, editor, commands, keybindings, pickers, renderers, screens/slots/dialogs, and themes; a web host carries analogous browser facets. Shared contracts are service tokens with JSON-safe DTOs, latest-value state, presentation-local screen/slot tokens, stable renderer discriminators, and portable structured errors. - -The generic kernel knows none of these domain concepts; each app host knows only its own. +The facet kernel is service-aware but application-agnostic. A complete product should supply independently loaded facet sets for server authority, each Session worker, and each presentation. Shared contracts contain service tokens and JSON-safe DTOs; they do not imply that the providing and consuming facets share a bundle. ## Open decisions -Before this becomes normative: +Before the extension layer becomes normative: -- the exact minimal kernel contract (environment shape, phase ordering, failure policy), the built-in manifest, and the concrete server/Session/TUI host services; -- the logical-manifest format, per-host entry-point resolution, and cross-process version pinning; -- whether directory state is projected per client (workspace-scoped snapshots) or one presentation-safe value plus method authorization; -- multiple selected Sessions per presentation connection (a multi-pane web UI) — deferred; it requires explicit routing for services from more than one selected Session; -- authentication of presentations and session workers, and protocol version negotiation; -- the exact lazy `Service` member-discovery and provider-kind-validation API, and the context-position and JSON-safe optional-argument policy from `rpc.md`; -- whether `ReplicatedState` is generic RPC infrastructure or host infrastructure; snapshot granularity; exact hydration/delivery-context semantics; -- replicated-state flow control and per-client buffering at the server; reference lifetime and garbage collection; -- the stable error envelope and expected-error registration; activation/disposal ordering, optional dependencies, and shared-proxy lifetime; -- the exact singleton/keyed mode validation, keyed provider/observe APIs, instance-key and generation rules, instance hydration protocol, and answer authorization; general memo-name ownership remains with the invocation-memo design; -- package boundaries between the generic kernel, agent plugin contracts, generic protocol machinery, and coding-agent host integration. +- what an `Extension` identifies and how it maps a version to independently bundled host facets; +- the manifest/source-selection format, ordering rules, package export convention, trust policy, and cross-process version skew; +- how structural graph replacement preserves host control and reports partial convergence; +- the concrete scoped server, Session, TUI, and future web capabilities; +- whether directory state is projected per authenticated client or globally presentation-safe; +- authentication, authorization, protocol version negotiation, and expected application error registration; +- optional service dependencies, multi-Session presentations, replicated-state flow control, and gap recovery; +- whether private returned references are needed after keyed services cover concrete features; and +- package boundaries between the facet kernel, service RPC, coding-agent host integration, and extension contracts. -## Testing strategy +## Required tests -The handoff should include a reusable two-transport test matrix (loopback plus a real framed transport) covering: +The test matrix covers: -- **Composition:** hosts start from one plugin manifest, not hard-coded features; per-host entry-point resolution with no Session modules reachable in a presentation bundle; setup-time `provide`/`provideMany`/`use`/`observe` calls produce the private dependency graph and provider-to-consumer edges; late service acquisition is rejected; provide/demand-resolve round trip; mixed singleton/keyed use rejected; shared proxy/replica across concurrent consumers; instance snapshot plus add/close/re-add reconciliation; local services unreachable remotely; duplicate/missing service failures; activation only after registration and dependency validation; reverse-order disposal. -- **Connections:** `Connect` performs one abortable attempt and the host owns retries; peer identity comes from the accepting server's handshake; session and presentation hosts connect to that server, and `server.local` runs the same host boundaries without sockets. -- **RPC and context:** JSON call and `void` result; invalid argument/result and unknown service/member rejection; client span → `rpc.client` → `rpc.server` → service span; per-call cancellation isolation; disconnect aborts active calls; no callback, context, signal, telemetry object, or secret in wire JSON. -- **State and events:** a cold replica has `.value === undefined` and does not invoke subscribers before hydration; snapshot plus concurrent update has no gap; instance discovery and member-state hydration do not miss an addition, update, or close; hydrated subscriptions immediately receive the current borrowed value; update ordering without defensive cloning; reconnect replaces stale state; provider switching clears readiness; subscribe/unsubscribe and disconnect cleanup; deliveries carry reconstructed source contexts; first snapshot callbacks run under the hydration context. -- **Registries:** ordered provider contributions rebuild deterministically; tool wrappers compose once and rebuild correctly after removal. -- **Presentation:** typed selections return values rather than labels; modal leases do not interleave multi-step dialogs; closing a queued instance removes it before display; closing an active instance aborts and dismisses only that dialog; non-cancellation observer failures reach host failure policy. -- **Routing:** attach authorizes at the server and rejects cross-workspace targets; attach/switch closes previous routed requests and instance tasks, then hydrates the selected session; a routed singleton or instance call reconstructs `Context` at the session worker, with cancellation and trace carriers traversing the server; stale instance generations are rejected; per-client keying prevents request-ID collisions; the server routes services whose contracts it does not load; session-worker failure leaves server services healthy and updates directory status; presentation disconnect cleanup reaches the session; summaries contain no `ownerId` or `cwd`. -- **Reload:** changed facet implementations replace their matching active facets while stable service facades rebind to new local implementations or RPC singleton snapshots; structural graph changes reassemble the affected host; old Session ownership closes before worker replacement; logical selection survives a route gap and receives a fresh attachment ID; old calls and frames are fenced; events are not replayed; interrupted mutations are not retried; removed plugin data is retained; temporary generation skew remains compatible; and failed replacement stays degraded without rollback. -- **Boundaries:** scoped agent and fleet capabilities cannot reach host ownership or whole-registry mutation; raw Harness/Session/SessionRepo capabilities unreachable from any client connection; job `wait` cancellation distinct from `job.cancel()`; stale/closed reference rejection; concurrent question invocations add distinct services visible in simultaneous TUI and web clients, survive having no connected presentation while the worker is live, disappear on worker loss, reappear under new generations when safe replay finds no answer memo, accept only one durable answer each, reject the tool wait rather than hang when the memo write fails, return committed answers without adding another instance on replay, clean up with durable tool-result staging, and close in every presentation; invocation cancellation writes no durable cancellation state. +- setup-derived provisions and requirements, late-access guards, missing and duplicate providers, mode validation, cycles, activation order, and reverse disposal; +- local and connected singleton calls, token-driven publication, strict JSON values, keyed instance hydration, generation fencing, cancellation, and selected-Session routing; +- cold state, snapshot/update races, buffering, update order, disconnect cleanup, and complete replacement snapshots; and +- static/combined loaders plus stable local and RPC singleton handles across shape-preserving provider reload. + +It also covers extension discovery and isolated process-specific bundle loading; structural graph reassembly and reload coordination; worker handoff with preserved logical selection and fresh attachment fencing; scoped host capabilities and contribution-registry rebuilds; authenticated routing and telemetry propagation; keyed-provider replacement and activation failure; and the question and collaborative-review examples below. ## Collaborative diff review: a durable shared sidebar @@ -1038,7 +997,7 @@ DiffReviews[reviewId] keyed service submit() freeze, enqueue one prompt, close ``` -The keyed instance is the live, reactive projection. A plugin-owned record is the durable authority. Pending comments are not weakly persisted: each acknowledged comment survives a worker restart, but the record is deleted after its prompt is durably accepted. +The keyed instance is the live, reactive projection. An extension-owned record is the durable authority. Pending comments are not weakly persisted: each acknowledged comment survives a worker restart, but the record is deleted after its prompt is durably accepted. ### Shared remote contract @@ -1086,56 +1045,13 @@ The client never supplies a patch, author, or review ID. The session computes a ### Narrow local durability capabilities -Unlike a question, this interaction has no invocation memo. The session facet therefore depends on local services backed by session storage and the durable prompt lane: - -```ts -type DiffReviewRecord = - | { - reviewId: string; - patch: string; - revision: number; - comments: DiffComment[]; - status: "open"; - submission: null; - } - | { - reviewId: string; - patch: string; - revision: number; - comments: DiffComment[]; - status: "submission_pending"; - submission: { submissionId: string; prompt: string }; - }; - -type OpenDiffReviewRecord = Extract; -type SubmittingDiffReviewRecord = Extract; - -interface DiffSourceService { - snapshotWorkingTree(context: Context): Promise; -} - -interface DiffReviewRecordsService { - listPending(context: Context): Promise; - create(patch: string, context: Context): Promise; - addComment(reviewId: string, input: DiffCommentInput, context: Context): Promise; - freezeForSubmission(reviewId: string, context: Context): Promise; - complete(reviewId: string, submissionId: string, context: Context): Promise; -} - -interface PromptQueueService { - enqueueOnce(submissionId: string, prompt: string, context: Context): Promise; -} - -const DiffSource = defineLocalService("diff-source"); -const DiffReviewRecords = defineLocalService("diff-review-records"); -const PromptQueue = defineLocalService("prompt-queue"); -``` +Unlike a question, this interaction has no invocation memo. The Session facet uses three process-local capabilities: a diff source that snapshots the working tree, a review store that serializes record mutations, and a prompt queue with idempotent `enqueueOnce()`. One durable review record contains the immutable patch, revisioned comments, status, and an optional frozen `{ submissionId, prompt }`. These local capabilities are ordinary `{ rpc: false }` services; their repository APIs are not part of the extension's shared contract. `DiffReviewRecords` serializes mutations per review. `addComment()` validates the anchor against the stored patch, stamps the authenticated author, deduplicates `commentId`, commits, and then returns the new revision. `freezeForSubmission()` atomically excludes later comments and stores a stable submission ID plus a prompt containing the immutable patch and that exact comment snapshot. If submission was already frozen, it returns the same record. `PromptQueue.enqueueOnce()` returns only after that logical prompt is durably accepted; retrying its submission ID cannot enqueue a second prompt. ### Why record mutations need a critical region -`DiffReviewRecords` builds on the facet's scoped Session data (`values.md`): typed durable values in a plugin-owned namespace. Each storage call is atomic, but an application read-modify-write cycle spans multiple calls and therefore multiple awaits. Concurrent service calls can interleave between them. +`DiffReviewRecords` builds on the facet's scoped Session data (`values.md`): typed durable values in an extension-owned namespace. Each storage call is atomic, but an application read-modify-write cycle spans multiple calls and therefore multiple awaits. Concurrent service calls can interleave between them. Concrete failure without serialization — two users press submit at the same time: @@ -1161,7 +1077,7 @@ async freezeForSubmission(reviewId, context) { if (current.status === "submission_pending") return current; // idempotent retry const submissionId = newSubmissionId(); - const frozen: SubmittingDiffReviewRecord = { + const frozen = { ...current, revision: current.revision + 1, status: "submission_pending", @@ -1184,66 +1100,25 @@ The requirement is one linearizable read-modify-write path per review, not speci ### Session facet -```ts -function toDiffReviewActivity(record: DiffReviewRecord): DiffReviewActivity { - return { - revision: record.revision, - comments: record.comments, - status: record.status === "open" ? "open" : "submitting", - }; -} +The Session facet follows a short reconstruction algorithm: -export function diffReviewSessionFacet(bindings: FacetEnvironment) { - const reviews = bindings.provideMany(DiffReviews); - const diffs = bindings.use(DiffSource); - const records = bindings.use(DiffReviewRecords); - const prompts = bindings.use(PromptQueue); +```text +activation +→ list pending review records +→ add one DiffReviews instance per record +→ publish document and activity state +→ resume any frozen submission through enqueueOnce() - function exposeReview(initial: DiffReviewRecord) { - const document = bindings.replicatedState({ reviewId: initial.reviewId, patch: initial.patch }); - const activity = bindings.replicatedState(toDiffReviewActivity(initial)); +createReview() +→ snapshot the diff +→ create the durable record +→ add its DiffReviews instance - function publish(next: DiffReviewRecord, context: Context) { - if (next.revision > activity.value.revision) activity.set(toDiffReviewActivity(next), context); - } - - async function finish(record: SubmittingDiffReviewRecord, context: Context) { - await prompts.enqueueOnce(record.submission.submissionId, record.submission.prompt, context); - await records.complete(record.reviewId, record.submission.submissionId, context); - close(); - } - - const close = reviews.add(initial.reviewId, { - document, - activity, - async addComment(input, context) { - publish(await records.addComment(initial.reviewId, input, context), context); - }, - async submit(context) { - const frozen = await records.freezeForSubmission(initial.reviewId, context); - publish(frozen, context); - await finish(frozen, context); - }, - }); - - return { finish }; - } - - bindings.provide(DiffReviewManager, { - async createReview(context) { - const patch = await diffs.snapshotWorkingTree(context); - const record = await records.create(patch, context); - exposeReview(record); - }, - }); - - bindings.onActivate(async () => { - for (const record of await records.listPending(BACKGROUND_CONTEXT)) { - const review = exposeReview(record); - if (record.status === "submission_pending") await review.finish(record, BACKGROUND_CONTEXT); - } - }); -} +submit() +→ atomically freeze comments and submission ID +→ publish "submitting" +→ enqueueOnce(submission ID, prompt) +→ delete the completed record and close the instance ``` Every mutation commits before `publish()`. Concurrent comment and submit calls are ordered by the record repository: a comment committed first is in the frozen prompt; a comment arriving after the freeze receives `review_closed`. `complete()` deletes only the matching frozen record, and `close()` is idempotent. @@ -1252,64 +1127,7 @@ The startup scan reconstructs every open keyed instance from durable records. A ### TUI and web facets -The plugin owns its TUI and browser widgets. Both implement this local presentation interface; it is not an RPC contract: - -```ts -type DiffReviewAction = - | { type: "add_comment"; input: DiffCommentInput; context: Context } - | { type: "submit"; context: Context }; - -interface DiffReviewPanel { - render(activity: DiffReviewActivity): void; - nextAction(signal: AbortSignal): Promise; - close(): void; -} - -function observeDiffReviews( - env: FacetEnvironment, - openPanel: (document: DiffReviewDocument) => DiffReviewPanel, -) { - env.own( - env.observe(DiffReviews, async (review, context) => { - const document = review.service.document.value; - if (document === undefined) throw new Error("Diff review was observed before hydration"); - const panel = openPanel(document); - const unsubscribe = review.service.activity.subscribe((activity) => panel.render(activity)); - - try { - while (true) { - const action = await panel.nextAction(context.abortSignal); - if (action === undefined) return; - if (action.type === "add_comment") { - await review.service.addComment(action.input, action.context); - } else { - await review.service.submit(action.context); - } - } - } finally { - unsubscribe(); - panel.close(); - } - }), - ); -} - -// tui.ts -export function diffReviewTuiFacet(env: FacetEnvironment) { - const manager = env.use(DiffReviewManager); - const tui = env.use(Tui); - tui.commands.register("diff.review", (context) => manager.createReview(context)); - observeDiffReviews(env, (document) => createTuiDiffReviewPanel(tui.slots, document)); -} - -// web.ts -export function diffReviewWebFacet(env: FacetEnvironment) { - const manager = env.use(DiffReviewManager); - const views = env.use(WebViews); - observeDiffReviews(env, (document) => createWebDiffReviewPanel(views, document)); - // The web plugin's "Review diff" button calls manager.createReview(context). -} -``` +The extension owns its TUI and browser widgets. TUI and web facets both `observe(DiffReviews, ...)`. Each observer opens a native panel from the hydrated document, subscribes to activity, forwards comment and submit actions to the service, and closes the panel when the instance context aborts. The TUI also registers a command that calls `DiffReviewManager.createReview()`; a web surface may expose the same operation as a button. `observe()` begins only after both state members hydrate. The panel receives the immutable document once, and subscribing to `activity` immediately renders current comments without retransmitting the patch on every edit. A late client sees the same pending review. Activity updates continue while `nextAction()` waits. Each sidebar action carries a fresh presentation-created `Context`; the longer-lived observation context controls only panel lifetime. When submission closes the keyed instance, every panel's observation context aborts and its `finally` block disposes the subscription and widget. @@ -1330,58 +1148,18 @@ This is a shared review, not a generic room primitive. Keyed services provide di ## Deferred: delta-based replicated state -> **Deferred:** `DeltaState` is not part of the initial plugin or RPC contract. Add it only after a concrete feature demonstrates that full-value `ReplicatedState` updates are too expensive and the same pattern appears in more than one feature. +> **Deferred:** `DeltaState` is not part of the initial facet-service or RPC contract. Add it only after a concrete feature demonstrates that full-value `ReplicatedState` updates are too expensive and the same pattern appears in more than one feature. A real replication gap remains. Some authoritative values are large, change frequently, and must support late joiners. `ReplicatedState` hydrates and reconnects correctly but sends a complete value on every update. -A canvas is one possible example: joining requires the complete document, while dragging a shape should ideally send only that operation. With today's primitives, the plugin must accept complete `ReplicatedState` updates or keep the high-frequency projection process-local. A concrete feature should establish the snapshot, delta, gap-recovery, and flow-control requirements before another remote primitive is added. +A canvas is one possible example: joining requires the complete document, while dragging a shape should ideally send only that operation. With today's primitives, the facet must accept complete `ReplicatedState` updates or keep the high-frequency projection process-local. A concrete feature should establish the snapshot, delta, gap-recovery, and flow-control requirements before another remote primitive is added. ### Possible future primitive -If repeated implementations justify extraction, a future `DeltaState` could provide `ReplicatedState` hydration while using deltas for steady-state transport: - -```ts -interface CanvasShape { - shapeId: string; - kind: "rect" | "ellipse" | "path"; - x: number; - y: number; - data: JsonValue; -} - -type CanvasOp = { type: "upsert"; shape: CanvasShape } | { type: "delete"; shapeId: string }; - -interface CanvasDocument { - shapes: Record; -} - -interface CanvasDelta { - author: string; - ops: CanvasOp[]; -} - -const CanvasDocumentState = defineDeltaState( - "canvas-document", - applyCanvasDelta, -); - -interface DeltaState { - /** `undefined` until the first authoritative snapshot arrives. */ - readonly value: S | undefined; - subscribe( - listener: (value: S, change: { type: "snapshot" } | { type: "delta"; delta: D }, context: Context) => void, - ): () => void; -} - -interface MutableDeltaState extends DeltaState { - readonly value: S; - apply(delta: D, context: Context): void; - replace(value: S, context: Context): void; -} -``` +If repeated implementations justify extraction, a future `DeltaState` could retain `ReplicatedState`'s synchronous value and snapshot hydration while delivering typed deltas after hydration. The provider would expose only `apply(delta, context)` and `replace(value, context)`; a shared pure reducer would update consumer replicas. The shared reducer is pure and deterministic. `apply()` synchronously reduces the provider's value and publishes one delta; `replace()` publishes a new authoritative snapshot. Business snapshots and deltas contain no transport revision. The host stamps revisions within the provider binding, buffers updates racing hydration, applies only consecutive frames, and requests a fresh snapshot after a gap or reconnect. -Supporting this requires an explicit new RPC member kind. The providing object carries the `DeltaState` definition ID; the provider announces that ID in member metadata; and the consuming host resolves the same imported definition before applying deltas locally. Until `rpc.md` specifies that registration and hydration protocol, `bindings.deltaState()` does not exist. +Supporting this requires an explicit RPC member kind. The providing object carries the `DeltaState` definition ID; the provider announces that ID in member metadata; and the consuming host resolves the same imported definition before applying deltas locally. The contract is adopted only together with that registration and hydration protocol. `DeltaState` would solve only live replication. It would not provide durable storage, mutation serialization, multi-writer merging, offline editing, or automatic mutation replay. A durable canvas would still serialize its own mutations, persist an admitted delta before publishing it, and coordinate log compaction with appends. Durable log cursors remain application/storage metadata and are independent of the host's transport revision. diff --git a/packages/agent/docs/rpc.md b/packages/agent/docs/rpc.md index 03ab992a0..c2d2412bf 100644 --- a/packages/agent/docs/rpc.md +++ b/packages/agent/docs/rpc.md @@ -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; + remove(sessionId: string, context: Context): Promise; attach(sessionId: string, context: Context): Promise; detach(context: Context): Promise; } @@ -157,7 +159,7 @@ const SessionDirectory = defineService("pi.session-directory") const SessionManagement = defineService("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); diff --git a/packages/agent/src/harness/agent-harness.ts b/packages/agent/src/harness/agent-harness.ts index bf526e59c..100e6261c 100644 --- a/packages/agent/src/harness/agent-harness.ts +++ b/packages/agent/src/harness/agent-harness.ts @@ -48,6 +48,7 @@ export { UnknownTarget, UnknownTemplate, } from "./result.ts"; +export { SliceNotImplemented } from "./runtime/types.ts"; import { createAgentHarness } from "./runtime/harness.ts"; import type { diff --git a/packages/agent/src/plugins/services/types.ts b/packages/agent/src/plugins/services/types.ts index 2961df9be..256ceb5d5 100644 --- a/packages/agent/src/plugins/services/types.ts +++ b/packages/agent/src/plugins/services/types.ts @@ -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"; } } diff --git a/packages/coding-agent/src/experimental/services/chat-provider.ts b/packages/coding-agent/src/experimental/services/chat-provider.ts index 81194a43d..a5c735f74 100644 --- a/packages/coding-agent/src/experimental/services/chat-provider.ts +++ b/packages/coding-agent/src/experimental/services/chat-provider.ts @@ -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(operation: string, callback: () => Promise): Promise { + 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) { diff --git a/packages/coding-agent/test/experimental-chat-service.test.ts b/packages/coding-agent/test/experimental-chat-service.test.ts index f80b6de71..fca2d4866 100644 --- a/packages/coding-agent/test/experimental-chat-service.test.ts +++ b/packages/coding-agent/test/experimental-chat-service.test.ts @@ -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({ diff --git a/packages/coding-agent/test/experimental-remote-runtime.test.ts b/packages/coding-agent/test/experimental-remote-runtime.test.ts index b47d98801..0b0cc4493 100644 --- a/packages/coding-agent/test/experimental-remote-runtime.test.ts +++ b/packages/coding-agent/test/experimental-remote-runtime.test.ts @@ -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); }); diff --git a/packages/protocol/README.md b/packages/protocol/README.md index 29eea1c16..e6219c1f8 100644 --- a/packages/protocol/README.md +++ b/packages/protocol/README.md @@ -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. diff --git a/packages/server/README.md b/packages/server/README.md index bad241549..d90954b9c 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -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.