From a17323e5b1e766433e76a3ed7a129f640924c079 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 20 Aug 2026 23:07:48 +0200 Subject: [PATCH] docs(agent): design plugin dependency discovery and reload --- .../agent/docs/extensions/pi-extensions-v2.md | 3 +- packages/agent/docs/plugin-reloading.md | 321 ++++++++++++++++++ packages/agent/docs/plugins.md | 82 +++-- packages/agent/docs/rpc.md | 25 +- 4 files changed, 404 insertions(+), 27 deletions(-) create mode 100644 packages/agent/docs/plugin-reloading.md diff --git a/packages/agent/docs/extensions/pi-extensions-v2.md b/packages/agent/docs/extensions/pi-extensions-v2.md index 0895685ae..407a76847 100644 --- a/packages/agent/docs/extensions/pi-extensions-v2.md +++ b/packages/agent/docs/extensions/pi-extensions-v2.md @@ -2,7 +2,8 @@ Status: **exploratory**. A working document, not a spec. Records what we learned from other harnesses, what we take and reject, and the current shape. Expected to -change. +change. `../plugins.md` and `../plugin-reloading.md` supersede its service-dependency, +reload, and rollback discussion where they conflict. --- diff --git a/packages/agent/docs/plugin-reloading.md b/packages/agent/docs/plugin-reloading.md new file mode 100644 index 000000000..af66400c4 --- /dev/null +++ b/packages/agent/docs/plugin-reloading.md @@ -0,0 +1,321 @@ +# 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 one complete application manifest generation across every host facet. It does not replace one service implementation, one registration, or one facet independently. + +One reload targets: + +- the server facet set in the server host; +- the session facet set in every live session worker; +- the presentation facet set in every connected TUI or web host; and +- the same facet set for hosts started after the reload. + +The hosts do not switch at one atomic instant. The server records one desired manifest generation and every host converges on it. A session worker or presentation may temporarily run a different generation while it stops, restarts, reconnects, or fails. Plugin contracts must remain forward compatible across this window. + +Reload uses ordinary host shutdown and startup. It has no stronger safety guarantee, no rollback guarantee, and no test that arbitrary in-process work is 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 a new manifest generation after plugin source or the selected plugin set changed. Every facet is replaced. +- **Restart** stops and recreates a concrete server, session worker, or presentation runtime while implementing a reload. +- **Rebind** moves a remote service namespace 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. **Host runtime state** is reconstructed. It includes Harness instances, extension runners, service providers, contribution drafts, listeners, timers, caches, open files, and subprocess handles. +3. **Presentation replicas** are rebound. They include `RemoteState` values, event listeners, 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 namespace 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 leaves its previous generation by ordinary shutdown and then starts the desired one. + +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. + +## 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 constructs facet contexts whose methods close over the host-assigned plugin ID and facet kind, so ownership remains correct even when setup functions run concurrently. + +During setup the host records: + +```text +provide(service, implementation) → this facet provides service/singleton +provideKeyed(service) → this facet provides service/keyed +use(service) → this facet requires service/singleton +observe(service, handler) → this facet requires service/keyed +``` + +The context path supplies the target scope: local `use()`, `server.use()`, and `session.use()` are different requirements. 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. `provideKeyed()` returns the only handle permitted to spawn that service's dynamic instances, allowing the provision to appear in the graph before any instance exists. + +After setup, each host emits a JSON-safe facet plan: + +```ts +interface ServiceKey { + scope: "local" | "server" | "session"; + serviceId: string; + mode: "singleton" | "keyed"; +} + +interface FacetServicePlan { + pluginId: string; + facet: "server" | "session" | "tui" | "web"; + requires: ServiceKey[]; + provides: ServiceKey[]; +} +``` + +Host control combines plans by scope, service ID, and mode. It rejects missing providers, duplicate singleton providers, mode mismatches, and invalid dependency cycles; derives service allowlists; and records provider-to-consumer edges for diagnostics and lifecycle ordering. `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 manifest without that plugin and runs the same assembly. If retained facets have unresolved hard requirements, the candidate is rejected before becoming the desired generation. If it is valid, removal still uses a complete-generation reload and simply brings back fewer facets. The removed plugin's durable namespace remains stored. + +The service graph does not replace the module loader's source import graph. Partial source invalidation would need both graphs: one for runtime service dependents and one for modules importing changed bytes. Complete-generation reload deliberately avoids that additional mechanism. A future partial unload would also require per-plugin scopes, per-service withdrawal and rebinding, local stale-reference fencing, and dependent deactivation ordering; the graph is necessary for that design but does not by itself provide it. + +## Reload sequence + +At the architectural level, a reload proceeds as follows: + +```text +user invokes /reload +→ server records desired manifest generation N+1 +→ application enters reloading presentation state +→ every live host stops its complete generation-N facet set +→ server/session/presentation runtimes restart on N+1 +→ connections and Session attachments are re-established +→ remote namespaces hydrate fresh snapshots +→ normal application interaction resumes where required hosts are ready +``` + +Module resolution and syntax may be checked before tearing down the old generation. Such preflight reduces avoidable failures but does not make reload transactional. Module evaluation may itself be defective, facet setup may fail only with host capabilities, and startup may fail after the old runtime has stopped. + +All facet-owned resources close through the ordinary lifecycle scope 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 host begins shutdown: + +- it stops accepting new work through the retiring binding; +- active invocation contexts, watches, subscriptions, and observations close through normal shutdown; +- 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. + +`SessionServices.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 remote service implementation belongs to its providing host generation. Services are not independently hot-swapped in the initial design. + +This matches the basic implementation: + +- `RemoteServiceProvider.provide()` installs one singleton for the provider's lifetime; +- plugin-facing `provideKeyed()` registers keyed ownership during setup, while its scoped handle delegates dynamic instances to provider `spawn()` and preserves generation fencing when a key is reused; +- disposing a provider removes its member listeners and subscriptions; +- a server connection or Session attachment change makes `RemoteServiceNamespace` close old subscriptions and hydrate new ones; and +- binding revisions and attachment IDs fence delayed frames. + +There is therefore no need for a singleton-provider replacement frame or public singleton generation merely to reload plugins. Replacing the server connection or Session attachment replaces the provider binding as a whole. A stable remote facade may remain a local object, but it becomes unready and receives a complete snapshot from the replacement before it is usable again. + +Changing the manifest may add or remove service tokens. Provider and consumer allowlists are loaded from their respective facets in the new manifest generation. During temporary version skew, a service absent from one side fails normally; forward-compatible plugin bundles must not assume every host has switched already. + +Local service implementations are replaced with their host generation as well. Captured local implementation objects from the retired generation are stale and must not be retained by host-global state. + +## 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. Directory events during the gap are not replayed. | +| `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 `RemoteState`. | +| `Transcript` | Session worker projection of durable lane state | Hydrate an authoritative revisioned snapshot, then resume semantic event delivery. Snapshot/event gap handling is required 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. `RemoteState` 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 events + +`RemoteState` 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. + +`RemoteEvents` is non-durable and non-replayed. Events emitted while no subscription is active are lost. A consumer that must recover after reload needs one of: + +- a `RemoteState` snapshot; +- a pull method returning current authority; or +- an application-level revisioned snapshot/event protocol such as `Transcript`. + +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. The new host generation collects the complete ordered 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 generation N stops and N+1 fails, the affected host remains 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; +- per-service hot module replacement; 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 scopes own registrations, listeners, subscriptions, and process-local effects; and +- remote namespace bindings become unavailable and rehydrate against replacement providers. + +Pi does not adopt Cordis's in-process component reload as its primary mechanism. Durable Session authority, process-per-session isolation, server replacement, and snapshot-based service rebinding make host-generation replacement the simpler boundary. 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. +- Loading only one facet of a multi-facet plugin on new source bytes. +- Opening the same Session in old and replacement workers concurrently. +- Treating `RemoteState` as the durable source rather than a reconstructible projection. +- Depending on `RemoteEvents` to repair missed state after restart. +- 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 lifecycle scope. +- Acquiring a service for the first time from a later command or callback, after dependency assembly has closed. +- Dynamically spawning a keyed service without first registering its owner through `provideKeyed()`. +- 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 new manifest generation for server, session, and presentation facets; +- setup-time service acquisition produces complete cross-facet requirement and provision plans 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 manifest or performs a complete reload 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 and events flow; +- non-durable events are not replayed, while snapshot-backed services recover current state; +- 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 aaf7bbe39..1cee7bc7c 100644 --- a/packages/agent/docs/plugins.md +++ b/packages/agent/docs/plugins.md @@ -1,14 +1,14 @@ # Coding-Agent Application Hosts and Plugin Facets -> **Status:** Tentative design input, not a normative contract or implementation handoff. The transport-neutral service token, provider, singleton `use()`, keyed `observe()`, and `RemoteState` substrate now have an experimental implementation, including `Models`, `Chat`, `SessionDirectory`, and `SessionManagement` vertical slices. `RemoteEvents` now has an experimental transport-neutral implementation with ordered non-replayed delivery, while the remaining documented built-in service contracts retain explicit `ServiceSliceNotImplemented` members. The application host contexts, plugin kernel, references, telemetry propagation, and most other example facets remain illustrative. 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:** Tentative design input, not a normative contract or implementation handoff. The transport-neutral service token, provider, singleton `use()`, keyed `observe()`, and `RemoteState` substrate now have an experimental implementation, including `Models`, `Chat`, `SessionDirectory`, and `SessionManagement` vertical slices. `RemoteEvents` now has an experimental transport-neutral implementation with ordered non-replayed delivery, while the remaining documented built-in service contracts retain explicit `ServiceSliceNotImplemented` members. The application host contexts, 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. -This document assumes you already understand `AgentHarness`, `AgentLane`, `Session`, `SessionTree`, `SessionRepo`, invocation `Context`, and telemetry. Read `rpc.md` for wire frames, remote references, subscriptions, and trace carriers, and `telemetry.md` for context propagation and cancellation semantics. +This document assumes you already understand `AgentHarness`, `AgentLane`, `Session`, `SessionTree`, `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. ## 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: -1. The **plugin kernel** owns generic lifecycle mechanics: ordered setup, activation, scoped resource ownership, failure rollback, and reverse-order disposal. It knows nothing about Harness, tools, TUI components, RPC services, connections, or coding-agent policy. +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 constructs the plugin bindings for that runtime. 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 receives only the bindings natural to its host instance and process. @@ -179,7 +179,7 @@ Host infrastructure calls `own()` for every service, contribution, or subscripti 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. -One process owns one session, and session authority never migrates. 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. +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. @@ -191,11 +191,43 @@ Facets communicate across processes through **services**. One token type gives a function defineRemoteService(id: string): RemoteService; ``` -The declaration lives in the shared contract module and creates nothing. `provide(service, implementation)` exposes one singleton; `spawn(service, key, implementation)` exposes a keyed instance. Consumers select the same mode with `use(service)` or `observe(service, handler)`. Within one host namespace, a token must stay in one mode: mixing `provide`/`use` with `spawn`/`observe` is an assembly or protocol error. +The declaration lives in the shared contract module and creates nothing. `provide(service, implementation)` exposes one singleton. `provideKeyed(service)` registers ownership of a keyed service during facet setup and returns a scoped provider whose later `spawn(key, implementation)` calls expose instances. Consumers select the same modes with `use(service)` or `observe(service, handler)`. Within one host namespace, a token must stay in one mode: mixing `provide`/`use` with `provideKeyed`/`observe` is an assembly or protocol error. -TypeScript types cannot produce runtime member metadata. Plugin authors nevertheless declare no parallel descriptor. When `provide()` or `spawn()` receives an implementation, the host classifies functions as remote methods and recognizes branded `RemoteState` and `RemoteEvents` values. It rejects unsupported members and announces the resulting member table over the transport. +```ts +interface KeyedServiceProvider { + spawn(key: string, implementation: T): () => void; +} +``` -`use()` on a singleton returns a stable lazy proxy synchronously, even before a 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. +TypeScript types cannot produce runtime member metadata. Plugin authors nevertheless declare no parallel member descriptor. When `provide()` or a keyed provider's `spawn()` receives an implementation, the host classifies functions as remote methods and recognizes branded `RemoteState` and `RemoteEvents` values. It rejects unsupported members and announces the resulting member table over the transport. + +`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. + +### 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 `RemoteService` or `LocalService` retains its stable ID at runtime; the API call supplies the mode and namespace; and the facet context closes over the host-assigned plugin and facet identity. + +The host records a generation-scoped ledger: + +```text +provide(Models, implementation) +→ @pi/providers-builtin:session provides session/models/singleton + +session.use(Models) +→ @pi/model-selection:tui requires session/models/singleton + +provideKeyed(QuestionDialogs) +→ @pi/question:session provides session/question-dialog/keyed + +session.observe(QuestionDialogs, handler) +→ @pi/question:tui requires session/question-dialog/keyed +``` + +The first `provide()`, `provideKeyed()`, `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 keyed instances use the setup-owned `KeyedServiceProvider`, so spawning and closing instances do not change the graph. + +After every facet has registered, the host resolves requirements to provisions, rejects missing providers, duplicate singleton owners, singleton/keyed mismatches, and invalid dependency cycles, derives service allowlists, and records consumer-to-provider edges for diagnostics and lifecycle ordering. `use()` and `observe()` declare hard requirements; optional dependencies require a future distinct acquisition API rather than inference from call failure. Each host can report its JSON-safe facet plan `{ pluginId, facet, requires, provides }` without loading another host's implementation modules; host control combines the plans by service scope and ID. + +Only dependencies acquired through host bindings 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). The models service — the authority behind the model picker and thinking-level control — exercises methods, replicated state, and multiple consumers. @@ -315,7 +347,7 @@ A service has **one owner and many consumers**. In singleton mode, `providersBui - **Local:** in the process that provides the singleton, `use()` is synchronous and returns the actual implementation object. No serialization, no proxy. - **Remote:** across a connection, `use()` synchronously returns a stable lazy proxy. Calls made while disconnected fail when invoked; state has no value until hydrated. Concurrent consumers of one token in one process share one proxy, one state replica, and one remote subscription. -Keyed services use `spawn()` and `observe()`. A keyed service is empty until its owner calls `spawn()`; observing it never creates an instance. `spawn(service, 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. +Keyed services use `provideKeyed()` and `observe()`. A keyed service is empty until its setup-owned provider handle calls `spawn()`; observing it never creates an instance. `provider.spawn(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. A spawned member has structural identity `(service, key, generation, member)`. Its `RemoteState` members therefore need no independent IDs. The instance directory is control-plane metadata, not a plugin-visible `RemoteState` containing proxies. Switching sessions aborts all observed instance tasks before hydrating the selected session's current instances. @@ -342,8 +374,8 @@ interface CodingAgentSessionPluginContext extends PluginLifecycleScope { // service/state infrastructure (session-host owned) provide(service: RemoteService | LocalService, implementation: T): void; + provideKeyed(service: RemoteService): KeyedServiceProvider; use(service: RemoteService | LocalService): T; - spawn(service: RemoteService, key: string, implementation: T): () => void; observe( service: RemoteService, handler: (instance: RemoteServiceInstance, context: Context) => void | Promise, @@ -489,7 +521,7 @@ Required behavior: 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. 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 retains the last value as stale display data alongside connection or attachment state. Reconnecting to the same provider replaces it with a fresh snapshot. Switching to a different provider clears readiness and `.value` becomes `undefined` until that provider hydrates; service-bound presentation resources are disposed as part of the switch. +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. Switching to a different provider or host generation clears readiness and `.value` becomes `undefined` until that provider hydrates; service-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. Anything a consumer must recover after reconnect is exposed as remote state or pulled through a remote method. Remote state is latest-value replication, not by itself durable session storage; the providing facet must reconstruct its authoritative value after a worker restart. @@ -587,7 +619,7 @@ interface IndexJob { } ``` -An `IndexService.start(root, context)` returning an `IndexJob` validates the root, creates its own `AbortController` and a detached telemetry root, and returns the job. The job crosses the wire as a private **remote object reference** (`rpc.md`) known only to that caller. If every attached presentation must discover a job, `spawn()` a keyed service instance instead. Discovery is the distinction: returned references are passed explicitly; spawned instances appear in `observe()` hydration. Both make the cancellation domains concrete, and both need explicit lifetime cleanup. +An `IndexService.start(root, context)` returning an `IndexJob` validates the root, creates its own `AbortController` and a detached telemetry root, and returns the job. The job crosses the wire as a private **remote object reference** (`rpc.md`) known only to that caller. If every attached presentation must discover a job, register a keyed service with `provideKeyed()` during setup and spawn a keyed instance instead. Discovery is the distinction: returned references are passed explicitly; spawned instances appear in `observe()` hydration. Both make the cancellation domains concrete, and both need explicit lifetime cleanup. ## The server: directory, management, and routing @@ -605,7 +637,7 @@ interface FleetPluginScope { interface CodingAgentServerPluginContext extends PluginLifecycleScope { readonly fleet: FleetPluginScope; - // plus the same provide/spawn/use/observe/remoteState/remoteEvents as the session host context + // plus the same provide/provideKeyed/use/observe/remoteState/remoteEvents as the session host context } ``` @@ -837,6 +869,8 @@ function questionResult(request: QuestionRequest, answer: string | null, wasCust ```ts // session.ts export function questionSessionFacet(bindings: CodingAgentSessionPluginContext) { + const dialogs = bindings.provideKeyed(QuestionDialogs); + bindings.tools.add((draft) => { draft.set("question", { label: "Question", @@ -854,7 +888,7 @@ export function questionSessionFacet(bindings: CodingAgentSessionPluginContext) if (response === undefined) { const completion = Promise.withResolvers(); const request = bindings.remoteState(params); - const close = bindings.spawn(QuestionDialogs, invocation.invocationId, { + const close = dialogs.spawn(invocation.invocationId, { request, async submitAnswer(candidate, _answerContext) { if (candidate.outcome === "selected" && params.options[candidate.index] === undefined) { @@ -884,7 +918,7 @@ export function questionSessionFacet(bindings: CodingAgentSessionPluginContext) } ``` -`spawn()` 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. +`dialogs.spawn()` 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. ### TUI and web facets: observe every dialog instance @@ -945,11 +979,15 @@ 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 spawns the same logical key with a new generation. If it dies after the commit, replay reads the answer and returns without spawning. 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. +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). -## Lifecycle and disposal +## Lifecycle, disposal, and reload -Every facet-owned resource — singleton registrations, still-open spawned instances, contributions, state subscriptions, watchers, timers, in-flight RPCs, subprocesses, overlays — is owned by the facet's kernel scope and disposed when that scope closes, in reverse manifest order. Scoped capabilities enforce this automatically for hooks and event subscriptions; `onDispose` handles the rest. Removing a plugin disposes its scope in every host that loaded a facet and rebuilds any contribution registries it touched; neither requires the plugin to undo anything by hand. +Every facet-owned resource — singleton registrations, still-open spawned instances, contributions, state subscriptions, watchers, timers, in-flight RPCs, subprocesses, overlays — is owned by the facet's kernel scope and disposed when that scope closes, in reverse manifest order. Scoped capabilities enforce this automatically for hooks and event subscriptions; `onDispose` handles the rest. Contribution registries rebuild from the remaining ordered contributions instead of asking a plugin to reverse shared mutations. + +Source reload does not replace one scope or service in place. One user-triggered `/reload` selects a new complete manifest generation for server, session, and presentation hosts. Each host closes its old facet set through ordinary shutdown, reconstructs runtime state from durable authority, and rebinds services from fresh snapshots. Hosts may temporarily run different generations; contracts must remain forward compatible during convergence. + +Reload has no safety or rollback guarantee beyond normal shutdown and restart. Committed durable state survives, removed plugin data is retained, interrupted calls are not automatically replayed, and non-restartable process effects may fail. See [`plugin-reloading.md`](plugin-reloading.md) for the complete design. ## Connection loss, errors, and security @@ -964,7 +1002,7 @@ Errors cross the wire as a JSON envelope `{ code, message, data? }` with stable Security rules every providing host (session and server alike) must enforce: -- service IDs are allowlisted by trusted manifests; `provide()` and `spawn()` expose only implementation functions and branded remote-state/event members, instance generations are host-owned, and local services are never discoverable remotely; +- service IDs are allowlisted by trusted manifests; `provide()`, `provideKeyed()`, and scoped provider handles expose only implementation functions and branded remote-state/event members, instance generations are host-owned, and local 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; @@ -989,20 +1027,21 @@ Before this becomes normative: - whether `RemoteState` is generic RPC infrastructure or host infrastructure; snapshot granularity; exact hydration/delivery-context semantics; - state/event 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, spawn/observe APIs, instance-key and generation rules, instance hydration protocol, and answer authorization; general memo-name ownership remains with the invocation-memo design; +- 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. ## Testing strategy The handoff should include a reusable two-transport test matrix (loopback plus a real framed transport) covering: -- **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; provide/demand-resolve round trip; mixed singleton/keyed use rejected; shared proxy/replica across concurrent consumers; instance snapshot plus spawn/close/re-spawn reconciliation; local services unreachable remotely; duplicate/missing service failures; activation only after registration; reverse-order disposal. +- **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`/`provideKeyed`/`use`/`observe` ledger produces complete facet plans 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 spawn/close/re-spawn 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 a spawn, 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:** one `/reload` selects one desired manifest generation for every facet kind; old Session ownership closes before replacement; logical selection survives a route gap and receives a fresh attachment ID; old calls and frames are fenced; state rehydrates from authoritative snapshots; 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 spawn 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 respawning on replay, clean up with durable tool-result staging, and close in every presentation; invocation cancellation writes no durable cancellation state. ## Collaborative diff review: a durable shared sidebar @@ -1180,6 +1219,7 @@ function toDiffReviewActivity(record: DiffReviewRecord): DiffReviewActivity { } export function diffReviewSessionFacet(bindings: CodingAgentSessionPluginContext) { + const reviews = bindings.provideKeyed(DiffReviews); const diffs = bindings.use(DiffSource); const records = bindings.use(DiffReviewRecords); const prompts = bindings.use(PromptQueue); @@ -1198,7 +1238,7 @@ export function diffReviewSessionFacet(bindings: CodingAgentSessionPluginContext close(); } - const close = bindings.spawn(DiffReviews, initial.reviewId, { + const close = reviews.spawn(initial.reviewId, { document, activity, async addComment(input, context) { diff --git a/packages/agent/docs/rpc.md b/packages/agent/docs/rpc.md index 6f55e3998..f27b08cfa 100644 --- a/packages/agent/docs/rpc.md +++ b/packages/agent/docs/rpc.md @@ -4,10 +4,10 @@ ## Role -`provide()`/`use()` and `spawn()`/`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. All connected hosts are assumed to use exactly matching contracts and protocol behavior; version negotiation and compatibility are deferred. +`provide()`/`use()` and `provideKeyed().spawn()`/`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. ```text -session/server facet: provide() / spawn() +session/server facet: provide() / provideKeyed().spawn() ↕ hidden service RPC presentation/session facet: use() / observe() ``` @@ -20,7 +20,7 @@ Do not serialize `Context`, `AbortSignal`, telemetry objects, callbacks, tools, ## Service contracts and typed facades -A remote-service token is a shared TypeScript contract and stable service ID. It is not a generated descriptor and creates no provider. `provide()` exposes one singleton implementation; `spawn()` exposes a keyed implementation. A token has one mode in one namespace: mixing singleton and keyed use is an error. +A remote-service token is a shared TypeScript contract and stable service ID. It is not a generated descriptor and creates no provider. `provide()` exposes one singleton implementation. `provideKeyed()` registers one keyed-service owner during facet setup and returns a scoped handle whose later `spawn()` calls expose instances. A token has one mode in one namespace: 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, `RemoteState`, or `RemoteEvents`. 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. @@ -30,6 +30,21 @@ Remote methods return promises and accept and return strict JSON apart from thei Use static assertions and runtime validation. Static checks constrain remote methods and state/event members; runtime boundaries reject unsupported members and non-JSON arguments, results, state, and event values. TypeScript supplies typed facades but does not authenticate a peer or create runtime metadata. +## Dependency ledger + +Type erasure does not hide service identity: every service token retains its stable ID at runtime. Facet contexts are created by the host with a non-forgeable owner identity, and their setup-time service methods append to a generation-scoped ledger: + +- `provide()` records a singleton provision; +- `provideKeyed()` records a keyed provision; +- `use()` records a singleton requirement; and +- `observe()` records a keyed requirement. + +The context or namespace on which the method is called supplies the service scope (`local`, `server`, or `session`). The method supplies the mode. The token supplies the ID. The host therefore needs no reflection over the erased `T` and no handwritten parallel dependency list. + +First acquisition or provision is permitted only during facet setup. Later commands, hooks, events, and activation callbacks use setup-acquired singleton facades, observer registrations, or keyed-provider handles. In particular, dynamic keyed instances are spawned through the handle returned by `provideKeyed()`; a late direct `spawn(service, ...)` cannot introduce a previously undeclared provision. + +After setup, each host produces a JSON-safe facet plan containing owner, facet kind, requirements, and provisions. Host control combines plans by scope, service ID, and mode to reject missing or duplicate providers and mode mismatches, derive allowlists, and record lifecycle edges. This service graph is distinct from the module loader's source import graph. + ## Namespaces, bindings, and identity The host exposes two remote namespaces: `server` names services provided by the connected server; `session` names services from the presentation's currently selected session. A session-service call never accepts a client-selected durable `sessionId`. The server authorizes attachment and routes the selected session namespace to its worker. @@ -96,7 +111,7 @@ Three cancellation domains remain separate: aborting one RPC invocation; explici ## Security and lifecycle -Only manifest-allowlisted remote service IDs may be provided or spawned. Local services 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 manifest-allowlisted remote service IDs may be provided or registered as keyed services. Only the owning keyed-provider handle may spawn instances. Local services 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. 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. @@ -106,7 +121,7 @@ Facet scopes own registrations, spawned instances, subscriptions, observations, Test the plugin-facing semantics over loopback and a real framed transport: -- local and remote `use()`, singleton/keyed mode validation, manifest allowlisting, lazy member access, and local services remaining unreachable remotely; +- setup-time dependency-ledger ownership, rejection of late acquisition, local and remote `use()`, keyed-provider ownership, singleton/keyed mode validation, manifest allowlisting, lazy member access, and local services remaining unreachable remotely; - strict JSON boundaries, method context reconstruction, request cancellation isolation, and trace propagation without serializing context values; - server/session namespace isolation, authorized attach, selected-session switching, stale-frame rejection, and worker-side per-client request correlation; - cold and hydrated `RemoteState`, snapshot/update race freedom, delivery contexts, stale display on reconnect, and clearing on provider switch;