From e54d14b59cfef6bd6d2ef491e259ab925fd6d19b Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 22 Aug 2026 00:37:02 +0200 Subject: [PATCH] feat(agent): simplify plugin service names --- packages/agent/README.md | 2 +- packages/agent/docs/plugin-reloading.md | 12 +- packages/agent/docs/plugins.md | 112 +++++++++--------- packages/agent/docs/rpc.md | 18 +-- packages/agent/src/plugins/services/index.ts | 10 +- .../agent/src/plugins/services/namespace.ts | 18 +-- .../agent/src/plugins/services/provider.ts | 15 ++- packages/agent/src/plugins/services/state.ts | 8 +- packages/agent/src/plugins/services/types.ts | 22 ++-- packages/agent/test/plugins/services.test.ts | 32 ++--- packages/client/src/client.ts | 2 +- packages/client/test/client.test.ts | 30 ++--- .../src/experimental/client-runtime.ts | 19 ++- .../src/experimental/client-tui.ts | 16 +-- .../src/experimental/services/accounts.ts | 8 +- .../src/experimental/services/chat.ts | 6 +- .../src/experimental/services/connection.ts | 10 +- .../src/experimental/services/models.ts | 8 +- .../src/experimental/services/sessions.ts | 12 +- .../src/experimental/services/transcript.ts | 6 +- .../test/experimental-chat-service.test.ts | 9 +- .../test/experimental-remote-runtime.test.ts | 4 +- .../test/experimental-service-stubs.test.ts | 17 ++- .../test/fixtures/keyed-service.ts | 8 +- .../test/fixtures/plugin-app/README.md | 6 +- .../test/fixtures/plugin-app/lib/api.ts | 6 +- .../test/fixtures/plugin-app/lib/client.ts | 10 +- .../test/fixtures/plugin-app/lib/session.ts | 6 +- .../fixtures/plugin-app/model-app/plugins.ts | 4 +- .../fixtures/plugin-app/model-app/services.ts | 8 +- .../fixtures/plugin-app/model-app/tui/app.ts | 4 +- .../plugin-app/model-app/tui/footer.ts | 6 +- .../model-app/tui/model-selector.ts | 4 +- .../plugin-app-model-vertical-slice.test.ts | 4 +- packages/protocol/src/protocol.ts | 14 +-- packages/protocol/test/protocol.test.ts | 30 ++--- packages/server/src/testing/client.ts | 8 +- packages/server/test/conformance.test.ts | 8 +- packages/server/test/protocol.test.ts | 6 +- 39 files changed, 267 insertions(+), 261 deletions(-) diff --git a/packages/agent/README.md b/packages/agent/README.md index 81422bd90..1bb1696b9 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -44,7 +44,7 @@ await agent.prompt("Hello!"); ## Experimental plugin services -The package exports the transport-neutral plugin-service primitives used by the experimental application hosts: `defineRemoteService()`, `remoteState()`, `RemoteServiceProvider`, and `RemoteServiceNamespace`. Providers classify implementation methods, state members, and event members at runtime; consumers receive stable singleton facades through `use()` or lifecycle-scoped keyed instances through `observe()`. `RemoteState` values and `RemoteEvents` deliveries are borrowed immutable JSON: the runtime does not defensively clone them, so callers must not mutate or retain them. Remote events are ordered, non-durable, and not replayed to late consumers. Wire framing and host routing remain outside this package. +The package exports the transport-neutral plugin-service primitives used by the experimental application hosts: `defineService()`, `remoteState()`, `RemoteServiceProvider`, and `RemoteServiceNamespace`. Providers classify implementation methods, state members, and event members at runtime; consumers receive stable singleton facades through `use()` or lifecycle-scoped keyed instances through `observe()`. `ReplicatedState` values and `RemoteEvents` deliveries are borrowed immutable JSON: the runtime does not defensively clone them, so callers must not mutate or retain them. Remote events are ordered, non-durable, and not replayed to late consumers. 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 index af66400c4..0545d1984 100644 --- a/packages/agent/docs/plugin-reloading.md +++ b/packages/agent/docs/plugin-reloading.md @@ -39,7 +39,7 @@ 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. +3. **Presentation replicas** are rebound. They include `ReplicatedState` 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. @@ -223,20 +223,20 @@ The experimental core services already illustrate the reconstruction rules: | `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`. | +| `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 | 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. +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 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. +`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. `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 `ReplicatedState` snapshot; - a pull method returning current authority; or - an application-level revisioned snapshot/event protocol such as `Transcript`. @@ -284,7 +284,7 @@ This also follows Cordis's system-boundary caveat: acquiring and releasing a pro - 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. +- Treating `ReplicatedState` 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. diff --git a/packages/agent/docs/plugins.md b/packages/agent/docs/plugins.md index 1cee7bc7c..e2d0efae8 100644 --- a/packages/agent/docs/plugins.md +++ b/packages/agent/docs/plugins.md @@ -1,6 +1,6 @@ # 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. 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:** 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. `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, `telemetry.md` for context propagation and cancellation semantics, and `plugin-reloading.md` for manifest-generation replacement and durable reconstruction. @@ -126,7 +126,7 @@ declare const serverAppHost: { }; interface RunningHost { - readonly connection: RemoteState; + readonly connection: ReplicatedState; stop(): Promise; } @@ -188,7 +188,7 @@ Out of scope: arbitrary undeclared object remoting, serialized functions/classes Facets communicate across processes through **services**. One token type gives a service contract its identity: ```ts -function defineRemoteService(id: string): RemoteService; +function defineService(id: string): Service; ``` 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. @@ -199,28 +199,28 @@ interface KeyedServiceProvider { } ``` -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. +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 `ReplicatedState` 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. +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` 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 +→ @pi/providers-builtin:session provides session/pi.models/singleton session.use(Models) -→ @pi/model-selection:tui requires session/models/singleton +→ @pi/model-selection:tui requires session/pi.models/singleton provideKeyed(QuestionDialogs) -→ @pi/question:session provides session/question-dialog/keyed +→ @pi/question:session provides session/pi.question-dialog/keyed session.observe(QuestionDialogs, handler) -→ @pi/question:tui requires session/question-dialog/keyed +→ @pi/question:tui requires session/pi.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. @@ -247,17 +247,17 @@ export interface ModelsState { | { status: "warning"; errors: Record }; } -export interface ModelsService { - readonly state: RemoteState; +export interface Models { + readonly state: ReplicatedState; cycleThinking(context: Context): Promise; refresh(context: Context): Promise; select(model: ModelRef, context: Context): Promise; } -export const Models = defineRemoteService("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 `RemoteState.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, 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. ### Session facet @@ -349,7 +349,7 @@ A service has **one owner and many consumers**. In singleton mode, `providersBui 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. +A spawned 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. Presentation facets consume remotely through two explicit namespaces — `server.use()`/`server.observe()` and `session.use()`/`session.observe()`, defined below. Session facets consume their own process's services locally and server services remotely. These bindings are host infrastructure layered over the kernel scope, not kernel API. @@ -373,14 +373,14 @@ interface CodingAgentSessionPluginContext extends PluginLifecycleScope { readonly agent: AgentPluginScope; // scoped local session authority // service/state infrastructure (session-host owned) - provide(service: RemoteService | LocalService, implementation: T): void; - provideKeyed(service: RemoteService): KeyedServiceProvider; - use(service: RemoteService | LocalService): T; + provide(service: Service | LocalService, implementation: T): void; + provideKeyed(service: Service): KeyedServiceProvider; + use(service: Service | LocalService): T; observe( - service: RemoteService, + service: Service, handler: (instance: RemoteServiceInstance, context: Context) => void | Promise, ): () => void; - remoteState(initial: T): MutableRemoteState; + remoteState(initial: T): MutableReplicatedState; remoteEvents(): MutableRemoteEvents; readonly server: ServerServices; // remote access to the managing server @@ -406,21 +406,21 @@ interface RemoteServiceInstance { } interface RemoteServiceNamespace { - use(service: RemoteService): T; + use(service: Service): T; observe( - service: RemoteService, + service: Service, handler: (instance: RemoteServiceInstance, context: Context) => void | Promise, ): () => void; } interface ServerServices extends RemoteServiceNamespace { - readonly connection: RemoteState; + readonly connection: ReplicatedState; } type AttachmentState = { status: "detached" } | { status: "attaching" | "attached" | "degraded"; sessionId: string }; interface SessionServices extends RemoteServiceNamespace { - readonly attachment: RemoteState; + readonly attachment: ReplicatedState; } interface SelectItem { @@ -485,28 +485,28 @@ Not every dependency should be remotely reachable. A **local service** is a toke ```ts const Credentials = defineLocalService("credentials"); // get/set provider secrets -interface AccountsService { - readonly state: RemoteState<{ providers: Array<{ provider: string; configured: boolean }> }>; +interface Accounts { + readonly state: ReplicatedState<{ providers: Array<{ provider: string; configured: boolean }> }>; remove(provider: string, context: Context): Promise; } -const Accounts = defineRemoteService("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. -## Replicated state: `RemoteState` +## Replicated state: `ReplicatedState` -`ModelsService.state` is a `RemoteState`: **authoritative latest-value replication** — not event history, durable storage, a CRDT, or a multi-writer mechanism. +`Models.state` is a `ReplicatedState`: **authoritative latest-value replication** — not event history, durable storage, a CRDT, or a multi-writer mechanism. ```ts -interface RemoteState { +interface ReplicatedState { /** Borrowed immutable value, or `undefined` until hydration. Do not mutate or retain it. */ readonly value: T | undefined; /** Listener values are borrowed and must not be mutated or retained. */ subscribe(listener: (value: T, context: Context) => void): () => void; } -interface MutableRemoteState extends RemoteState { +interface MutableReplicatedState extends ReplicatedState { /** A providing state is always initialized. */ readonly value: T; /** Transfers the JSON value to the state; the caller must not subsequently mutate it. */ @@ -524,7 +524,7 @@ Required behavior: 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. +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 should use semantic deltas plus a final authoritative replacement. Revision metadata, gap recovery, unchanged-value suppression, and demand-driven subscription are protocol concerns, not plugin-author concerns. @@ -597,15 +597,15 @@ A transport disconnect performs only the first for active requests and closes th A remotely exposed event source is a projection of host-local events, not a remotely invoked callback. A Git plugin: ```ts -interface GitService { - readonly status: RemoteState; +interface Git { + readonly status: ReplicatedState; readonly events: RemoteEvents; // { type: "status_changed" | "head_changed" } refresh(context: Context): Promise; } -const Git = defineRemoteService("git"); +const Git = defineService("pi.git"); ``` -Server-side, the exposure adapter subscribes to the host-local source once per remote subscription and forwards frames. Client-side, `events` is a local facade whose listeners run in the client process — callbacks never cross the wire; `on(type, listener)` filters by discriminator, `subscribe(listener)` observes everything. Each event carries source trace metadata from the providing host's context, so client-side handling stays correlated with the originating operation. The adapter owns subscribe/unsubscribe frames, sequencing, buffering, flow control, and disconnect cleanup (`rpc.md`). Remote events are non-durable and never replayed: a new or reconnected consumer sees only events emitted after its subscription becomes active. Anything needed to reconstruct current behavior after reconnect belongs in remote state or a pull method. +Server-side, the exposure adapter subscribes to the host-local source once per remote subscription and forwards frames. Client-side, `events` is a local facade whose listeners run in the client process — callbacks never cross the wire; `on(type, listener)` filters by discriminator, `subscribe(listener)` observes everything. Each event carries source trace metadata from the providing host's context, so client-side handling stays correlated with the originating operation. The adapter owns subscribe/unsubscribe frames, sequencing, buffering, flow control, and disconnect cleanup (`rpc.md`). Remote events are non-durable and never replayed: a new or reconnected consumer sees only events emitted after its subscription becomes active. Anything needed to reconstruct current behavior after reconnect belongs in replicated state or a pull method. ## Service-owned jobs @@ -613,7 +613,7 @@ Long-running work should return a capability rather than one method that blocks ```ts interface IndexJob { - readonly progress: RemoteState; + readonly progress: ReplicatedState; wait(context: Context): Promise; // aborting this context cancels only this wait cancel(context: Context): Promise; // cancels the job itself, for everyone } @@ -681,21 +681,21 @@ export type SessionDirectoryEvent = | { type: "created" | "changed"; session: SessionRecordSummary } | { type: "deleted"; sessionId: string }; -export interface SessionDirectoryService { - readonly state: RemoteState<{ revision: number; sessions: SessionRecordSummary[] }>; +export interface SessionDirectory { + readonly state: ReplicatedState<{ revision: number; sessions: SessionRecordSummary[] }>; readonly events: RemoteEvents; } -export const SessionDirectory = defineRemoteService("session-directory"); +export const SessionDirectory = defineService("pi.session-directory"); -export interface SessionManagementService { +export interface SessionManagement { create(options: { title: string }, context: Context): Promise; remove(sessionId: string, context: Context): Promise; attach(sessionId: string, context: Context): Promise; detach(context: Context): Promise; } -export const SessionManagement = defineRemoteService("session-management"); +export const SessionManagement = defineService("pi.session-management"); ``` ### Server facet @@ -841,12 +841,12 @@ interface QuestionDetails { wasCustom: boolean; } -interface QuestionDialogService { - readonly request: RemoteState; +interface QuestionDialogs { + readonly request: ReplicatedState; submitAnswer(response: QuestionResponse, context: Context): Promise; } -const QuestionDialogs = defineRemoteService("question-dialog"); +const QuestionDialogs = defineService("pi.question-dialog"); ``` `QuestionDialogs` declares only the contract. Each invocation explicitly spawns one keyed instance. Its `request` state is addressed by the service, invocation key, hidden generation, and member name. @@ -1002,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()`, `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; +- service IDs are allowlisted by trusted manifests; `provide()`, `provideKeyed()`, and scoped provider handles expose only implementation functions and branded replicated-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; @@ -1023,8 +1023,8 @@ Before this becomes normative: - 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 changes the session-namespace API; - authentication of presentations and session workers, and protocol version negotiation; -- the exact lazy `RemoteService` member-discovery and provider-kind-validation API, and the context-position and JSON-safe optional-argument policy from `rpc.md`; -- whether `RemoteState` is generic RPC infrastructure or host infrastructure; snapshot granularity; exact hydration/delivery-context semantics; +- 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; - 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, 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; @@ -1092,19 +1092,19 @@ interface DiffReviewActivity { status: "open" | "submitting"; } -interface DiffReviewManagerService { +interface DiffReviewManager { createReview(context: Context): Promise; } -interface DiffReviewService { - readonly document: RemoteState; - readonly activity: RemoteState; +interface DiffReviews { + readonly document: ReplicatedState; + readonly activity: ReplicatedState; addComment(input: DiffCommentInput, context: Context): Promise; submit(context: Context): Promise; } -const DiffReviewManager = defineRemoteService("diff-review-manager"); -const DiffReviews = defineRemoteService("diff-review"); +const DiffReviewManager = defineService("pi.diff-review-manager"); +const DiffReviews = defineService("pi.diff-review"); ``` The client never supplies a patch, author, or review ID. The session computes a bounded immutable patch, creates the ID, and derives each author from the authenticated identity in `Context`. `commentId` is only an idempotency key; it grants no authority. @@ -1353,9 +1353,9 @@ 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 `RemoteState` updates are too expensive and the same pattern appears in more than one feature. +> **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. -A real replication gap remains. Some authoritative values are large, change frequently, and must support late joiners. `RemoteState` hydrates and reconnects correctly but sends a complete value on every update. `RemoteEvents` can send small deltas but has no snapshot, replay, or automatic gap recovery. +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. `RemoteEvents` can send small deltas but has no snapshot, replay, or automatic gap recovery. A canvas is one possible example: joining requires the complete document, while dragging a shape should send only that operation. With today's primitives, the plugin can expose `getSnapshot()` plus revisioned `RemoteEvents` and implement the join protocol itself: @@ -1371,7 +1371,7 @@ That feature-local protocol is the preferred initial solution. It keeps a hypoth ### Possible future primitive -If repeated implementations justify extraction, a future `DeltaState` could provide `RemoteState` hydration while using deltas for steady-state transport: +If repeated implementations justify extraction, a future `DeltaState` could provide `ReplicatedState` hydration while using deltas for steady-state transport: ```ts interface CanvasShape { diff --git a/packages/agent/docs/rpc.md b/packages/agent/docs/rpc.md index f27b08cfa..de5654770 100644 --- a/packages/agent/docs/rpc.md +++ b/packages/agent/docs/rpc.md @@ -22,7 +22,7 @@ Do not serialize `Context`, `AbortSignal`, telemetry objects, callbacks, tools, 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. +The provider must make enough control-plane information available for the host to validate an accessed member as a method, `ReplicatedState`, 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. A local `use()` returns the actual implementation. A remote `use()` returns one stable, lazy typed facade shared by local consumers of that token. While disconnected, invoking a method fails and state remains unhydrated; no call is queued merely because it was made through a proxy. @@ -80,13 +80,13 @@ The client maps `context.abortSignal` to cancellation of that one request. Disco ## Replicated state, events, and keyed instances -`RemoteState` 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. +`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. `RemoteEvents` is a projection of provider-local events. Listeners run only in the consuming process; callbacks never cross the wire. Events are non-durable and never replayed after reconnect. Event delivery carries source trace metadata. State subscriptions, event subscriptions, and their cleanup are host-owned resources, not retained caller contexts. -`observe()` is keyed-instance discovery, not a `RemoteState` 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 stable `session.observe()` registration aborts old tasks and reconciles the fresh directory on a binding switch. +`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 stable `session.observe()` registration aborts old tasks and reconciles the fresh directory on a binding switch. ## Private returned references @@ -124,7 +124,7 @@ Test the plugin-facing semantics over loopback and a real framed transport: - 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; +- cold and hydrated `ReplicatedState`, snapshot/update race freedom, delivery contexts, stale display on reconnect, and clearing on provider switch; - event subscription setup, non-replay, ordering, cleanup, and bounded flow control; - 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 @@ -151,18 +151,18 @@ type SessionDirectoryEvent = | { type: "created" | "changed"; session: SessionSummary } | { type: "deleted"; sessionId: string }; -interface SessionDirectoryService { - readonly state: RemoteState<{ revision: number; sessions: SessionSummary[] }>; +interface SessionDirectory { + readonly state: ReplicatedState<{ revision: number; sessions: SessionSummary[] }>; readonly events: RemoteEvents; } -interface SessionManagementService { +interface SessionManagement { attach(sessionId: string, context: Context): Promise; detach(context: Context): Promise; } -const SessionDirectory = defineRemoteService("session-directory"); -const SessionManagement = defineRemoteService("session-management"); +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: diff --git a/packages/agent/src/plugins/services/index.ts b/packages/agent/src/plugins/services/index.ts index 8186b2be2..376cec513 100644 --- a/packages/agent/src/plugins/services/index.ts +++ b/packages/agent/src/plugins/services/index.ts @@ -22,22 +22,21 @@ export { } from "./state.ts"; export { cloneJson, - defineRemoteService, + defineService, isJsonValue, type MutableRemoteEvents, - type MutableRemoteState, + type MutableReplicatedState, type RemoteEventListener, type RemoteEvents, type RemoteEventType, type RemoteJson, - type RemoteService, type RemoteServiceConnection, type RemoteServiceInstance, type RemoteServiceNamespaceApi, type RemoteServiceNamespaceOptions, type RemoteServiceSubscription, - type RemoteServiceType, - type RemoteState, + type ReplicatedState, + type Service, type ServiceCall, type ServiceInstanceAddress, type ServiceInstanceSnapshot, @@ -49,4 +48,5 @@ export { ServiceSliceNotImplemented, type ServiceStateSnapshot, type ServiceSubscriptionSnapshot, + type ServiceType, } from "./types.ts"; diff --git a/packages/agent/src/plugins/services/namespace.ts b/packages/agent/src/plugins/services/namespace.ts index 5b3350258..5db24aa1b 100644 --- a/packages/agent/src/plugins/services/namespace.ts +++ b/packages/agent/src/plugins/services/namespace.ts @@ -8,13 +8,13 @@ import { type RemoteEventListener, type RemoteEvents, type RemoteEventType, - type RemoteService, type RemoteServiceConnection, type RemoteServiceInstance, type RemoteServiceNamespaceApi, type RemoteServiceNamespaceOptions, type RemoteServiceSubscription, - type RemoteState, + type ReplicatedState, + type Service, type ServiceInstanceAddress, type ServiceInstanceSnapshot, type ServiceMemberDescription, @@ -25,7 +25,7 @@ import { type ErrorReporter = (error: Error) => void; -class RemoteStateReplica implements RemoteState { +class RemoteStateReplica implements ReplicatedState { readonly #listeners = new Set<(value: JsonValue, context: Context) => void>(); readonly #reportError: ErrorReporter; #value: JsonValue | undefined; @@ -52,9 +52,9 @@ class RemoteStateReplica implements RemoteState { } update(sequence: number, value: JsonValue, context: Context): void { - if (this.#sequence === undefined) throw new Error("Remote state received an update before hydration"); + if (this.#sequence === undefined) throw new Error("Replicated state received an update before hydration"); if (sequence <= this.#sequence) return; - if (sequence !== this.#sequence + 1) throw new Error("Remote state update sequence has a gap"); + if (sequence !== this.#sequence + 1) throw new Error("Replicated state update sequence has a gap"); this.#sequence = sequence; this.#value = value; this.#deliverAll(context); @@ -416,7 +416,7 @@ interface ObserverRegistration { } class KeyedBinding { - readonly #service: RemoteService; + readonly #service: Service; readonly #connection: RemoteServiceConnection; readonly #reportError: ErrorReporter; readonly #onEmpty: () => void; @@ -430,7 +430,7 @@ class KeyedBinding { #revision = 0; constructor( - service: RemoteService, + service: Service, connection: RemoteServiceConnection, reportError: ErrorReporter, onEmpty: () => void, @@ -633,7 +633,7 @@ export class RemoteServiceNamespace implements RemoteServiceNamespaceApi { this.#bound = options.bound ?? true; } - use(service: RemoteService): T { + use(service: Service): T { this.#assertAvailable(service.id, "singleton"); let binding = this.#singletons.get(service.id); if (binding !== undefined) return binding.facade.proxy as T; @@ -651,7 +651,7 @@ export class RemoteServiceNamespace implements RemoteServiceNamespaceApi { } observe( - service: RemoteService, + service: Service, handler: (instance: RemoteServiceInstance, context: Context) => void | Promise, ): () => void { this.#assertAvailable(service.id, "keyed"); diff --git a/packages/agent/src/plugins/services/provider.ts b/packages/agent/src/plugins/services/provider.ts index 45e9efadf..07b6ba73f 100644 --- a/packages/agent/src/plugins/services/provider.ts +++ b/packages/agent/src/plugins/services/provider.ts @@ -5,8 +5,8 @@ import { freshDeliveryContext, getRemoteStateInternals, type RemoteStateInternal import { cloneJson, isJsonValue, - type RemoteService, type RemoteServiceConnection, + type Service, type ServiceCall, type ServiceInstanceAddress, type ServiceInstanceSnapshot, @@ -80,7 +80,7 @@ export class RemoteServiceProvider { this.#allowlist = new Set(ids); } - provide(service: RemoteService, implementation: T): void { + provide(service: Service, implementation: T): void { this.#assertActive(); this.#assertAllowed(service.id); const registration = this.#registration(service.id, "singleton", true); @@ -91,7 +91,7 @@ export class RemoteServiceProvider { registration.singleton = instance; } - use(service: RemoteService): T { + use(service: Service): T { this.#assertActive(); this.#assertAllowed(service.id); const registration = this.#registrations.get(service.id); @@ -101,7 +101,7 @@ export class RemoteServiceProvider { return registration.singleton.implementation as T; } - spawn(service: RemoteService, key: string, implementation: T): () => void { + spawn(service: Service, key: string, implementation: T): () => void { this.#assertActive(); this.#assertAllowed(service.id); if (key.length === 0) throw new TypeError("Remote service instance key must not be empty"); @@ -270,7 +270,10 @@ export class RemoteServiceProvider { state.subscribe((value, sequence, context) => { if (!instance.active) return; if (!isJsonValue(value)) { - throw new RemoteServiceError("service_invalid_value", "Remote state update must be strict JSON"); + throw new RemoteServiceError( + "service_invalid_value", + "Replicated state update must be strict JSON", + ); } this.#emit( registration, @@ -370,7 +373,7 @@ export class RemoteServiceProvider { members.push({ name, kind: member.kind }); if (member.kind === "state") { if (!isJsonValue(member.state.value)) { - throw new RemoteServiceError("service_invalid_value", "Remote state snapshot must be strict JSON"); + throw new RemoteServiceError("service_invalid_value", "Replicated state snapshot must be strict JSON"); } states[name] = { sequence: member.state.sequence, value: member.state.value }; } diff --git a/packages/agent/src/plugins/services/state.ts b/packages/agent/src/plugins/services/state.ts index 0193af5a2..c38c5cf6a 100644 --- a/packages/agent/src/plugins/services/state.ts +++ b/packages/agent/src/plugins/services/state.ts @@ -1,5 +1,5 @@ import { BACKGROUND_CONTEXT, type Context, withCancel } from "../../harness/context.ts"; -import { isJsonValue, type MutableRemoteState, type RemoteState } from "./types.ts"; +import { isJsonValue, type MutableReplicatedState, type ReplicatedState } from "./types.ts"; export const REMOTE_STATE_INTERNALS = Symbol("pi.remoteState.internals"); @@ -9,7 +9,7 @@ export interface RemoteStateInternals { subscribe(listener: (value: T, sequence: number, context: Context) => void): () => void; } -export interface RemoteStateSource extends MutableRemoteState { +export interface RemoteStateSource extends MutableReplicatedState { readonly [REMOTE_STATE_INTERNALS]: RemoteStateInternals; } @@ -59,7 +59,7 @@ class MutableRemoteStateImpl implements RemoteStateSource { } } -export function remoteState(initial: T): MutableRemoteState { +export function remoteState(initial: T): MutableReplicatedState { return new MutableRemoteStateImpl(initial); } @@ -79,4 +79,4 @@ function assertStateValue(value: unknown): void { if (!isJsonValue(value)) throw new TypeError("Remote service value must be strict JSON"); } -export type { RemoteState }; +export type { ReplicatedState }; diff --git a/packages/agent/src/plugins/services/types.ts b/packages/agent/src/plugins/services/types.ts index 7e5753127..52029bba1 100644 --- a/packages/agent/src/plugins/services/types.ts +++ b/packages/agent/src/plugins/services/types.ts @@ -1,7 +1,7 @@ import type { Context } from "../../harness/context.ts"; import type { JsonValue } from "../../harness/session/types.ts"; -const REMOTE_SERVICE_TYPE = Symbol("pi.remoteService.type"); +const SERVICE_TYPE = Symbol("pi.service.type"); export type ServiceMode = "singleton" | "keyed"; export type ServiceMemberKind = "method" | "state" | "events"; @@ -15,14 +15,14 @@ export class ServiceSliceNotImplemented extends Error { } } -export interface RemoteState { +export interface ReplicatedState { /** Borrowed immutable value, or undefined until hydration. Do not mutate or retain it. */ readonly value: T | undefined; /** Listener values are borrowed and must not be mutated or retained. */ subscribe(listener: (value: T, context: Context) => void): () => void; } -export interface MutableRemoteState extends RemoteState { +export interface MutableReplicatedState extends ReplicatedState { readonly value: T; /** Transfers the JSON value to the state; the caller must not subsequently mutate it. */ set(value: T, context: Context): void; @@ -61,7 +61,7 @@ type InvalidJsonPart = typeof REMOTE_JSON_TYPE extends keyof T ? { [TKey in keyof T]-?: InvalidJsonPart }[keyof T] : T; -type InvalidRemoteMember = T extends RemoteState +type InvalidRemoteMember = T extends ReplicatedState ? InvalidJsonPart extends never ? never : "state value is not JSON" @@ -77,7 +77,7 @@ type InvalidRemoteMember = T extends RemoteState ? never : "method result is not JSON or void" : "method argument is not JSON" - : "member is not a remote method, RemoteState, or RemoteEvents"; + : "member is not a remote method, ReplicatedState, or RemoteEvents"; type InvalidRemoteMemberNames = { [TKey in keyof T]-?: InvalidRemoteMember extends never ? never : TKey; @@ -86,14 +86,14 @@ type InvalidRemoteMemberNames = { type CheckedRemoteContract = InvalidRemoteMemberNames extends never ? T : never; /** Stable identity for one shared TypeScript service contract. */ -export interface RemoteService { +export interface Service { readonly id: string; - readonly [REMOTE_SERVICE_TYPE]?: (value: T) => T; + readonly [SERVICE_TYPE]?: (value: T) => T; } -export type RemoteServiceType = TService extends RemoteService ? T : never; +export type ServiceType = TService extends Service ? T : never; -export function defineRemoteService(id: string): RemoteService> { +export function defineService(id: string): Service> { if (id.length === 0) throw new TypeError("Remote service ID must not be empty"); if (id.startsWith("$pi.")) throw new TypeError("Remote service IDs beginning with $pi. are reserved"); return Object.freeze({ id }); @@ -186,9 +186,9 @@ export interface RemoteServiceNamespaceOptions { } export interface RemoteServiceNamespaceApi { - use(service: RemoteService): T; + use(service: Service): T; observe( - service: RemoteService, + service: Service, handler: (instance: RemoteServiceInstance, context: Context) => void | Promise, ): () => void; dispose(context: Context): Promise; diff --git a/packages/agent/test/plugins/services.test.ts b/packages/agent/test/plugins/services.test.ts index 8851bd27b..590d25cbd 100644 --- a/packages/agent/test/plugins/services.test.ts +++ b/packages/agent/test/plugins/services.test.ts @@ -3,12 +3,12 @@ import type { Context } from "../../src/harness/context.ts"; import { BACKGROUND_CONTEXT, createLoopbackServiceConnection, - defineRemoteService, + defineService, type RemoteEvents, type RemoteServiceConnection, RemoteServiceNamespace, RemoteServiceProvider, - type RemoteState, + type ReplicatedState, remoteEvents, remoteState, } from "../../src/index.ts"; @@ -18,27 +18,27 @@ type ModelsState = { selected: ModelRef | null; revision: number; }; -interface ModelsService { - readonly state: RemoteState; +interface Models { + readonly state: ReplicatedState; select(model: ModelRef, context: Context): Promise; } -const Models = defineRemoteService("models"); +const Models = defineService("test.models"); type Question = { question: string }; -interface QuestionDialogService { - readonly request: RemoteState; +interface QuestionDialogs { + readonly request: ReplicatedState; submit(answer: string, context: Context): Promise<{ accepted: boolean }>; } -const QuestionDialogs = defineRemoteService("question-dialog"); +const QuestionDialogs = defineService("test.question-dialog"); type ActivityEvent = { type: "changed"; revision: number } | { type: "ignored"; revision: number }; -interface ActivityService { +interface Activity { readonly events: RemoteEvents; } -const Activity = defineRemoteService("activity"); +const Activity = defineService("test.activity"); describe("plugin remote services", () => { test("does not defensively clone borrowed state values", () => { @@ -139,7 +139,7 @@ describe("plugin remote services", () => { provider.dispose(); }); - test("hydrates cold RemoteState replicas and replaces them across rebinds", async () => { + test("hydrates cold ReplicatedState replicas and replaces them across rebinds", async () => { const provider = new RemoteServiceProvider([Models]); const state = remoteState({ selected: null, revision: 0 }); provider.provide(Models, { @@ -188,7 +188,7 @@ describe("plugin remote services", () => { const observed: { key: string; question: Question | undefined; - service: QuestionDialogService; + service: QuestionDialogs; context: Context; }[] = []; const stop = namespace.observe(QuestionDialogs, (instance, context) => { @@ -308,7 +308,7 @@ describe("plugin remote services", () => { services: [Activity], connection: createLoopbackServiceConnection(provider), }); - let observed: ActivityService | undefined; + let observed: Activity | undefined; const stop = namespace.observe(Activity, (instance) => { observed = instance.service; }); @@ -334,10 +334,10 @@ describe("plugin remote services", () => { state: remoteState({ selected: null, revision: 0 }), async select() {}, }); - expect(() => provider.spawn(Models, "wrong", {} as ModelsService)).toThrow(/singleton/); + expect(() => provider.spawn(Models, "wrong", {} as Models)).toThrow(/singleton/); expect(() => provider.spawn(QuestionDialogs, "invalid", { - request: new Date() as unknown as RemoteState, + request: new Date() as unknown as ReplicatedState, async submit() { return { accepted: true }; }, @@ -346,7 +346,7 @@ describe("plugin remote services", () => { await expect( provider.invoke( { - serviceId: "models", + serviceId: "test.models", member: "select", args: [Number.NaN as unknown as number], }, diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 07a65977b..f551788a5 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -179,7 +179,7 @@ export class Client { } const result = await this.#request( { serverId: this.#options.serverId }, - { serviceId: "session-management", member: "attach", args: [sessionId] }, + { serviceId: "pi.session-management", member: "attach", args: [sessionId] }, ); if ( typeof result === "object" && diff --git a/packages/client/test/client.test.ts b/packages/client/test/client.test.ts index 02106bc51..b60f951cb 100644 --- a/packages/client/test/client.test.ts +++ b/packages/client/test/client.test.ts @@ -81,7 +81,7 @@ describe("Client service operations", () => { type: "request", id: "request-1", target: { serverId: "00000000-0000-4000-8000-000000000001" }, - call: { serviceId: "session-directory", member: "list", args: [] }, + call: { serviceId: "pi.session-directory", member: "list", args: [] }, }); server.send({ type: "response", @@ -108,7 +108,7 @@ describe("Client service operations", () => { expect(server.messages[2]).toMatchObject({ type: "request", target: { serverId: "00000000-0000-4000-8000-000000000001" }, - call: { serviceId: "session-management", member: "create", args: [{}] }, + call: { serviceId: "pi.session-management", member: "create", args: [{}] }, }); server.send({ type: "response", @@ -131,7 +131,7 @@ describe("Client service operations", () => { expect(server.messages[3]).toMatchObject({ type: "request", target: { serverId: "00000000-0000-4000-8000-000000000001" }, - call: { serviceId: "session-management", member: "attach", args: ["session-1"] }, + call: { serviceId: "pi.session-management", member: "attach", args: ["session-1"] }, }); server.send({ type: "response", @@ -150,7 +150,7 @@ describe("Client service operations", () => { sessionId: "session-1", attachmentId: "attachment-1", }, - call: { serviceId: "chat", member: "prompt", args: [["Hello"]] }, + call: { serviceId: "pi.chat", member: "prompt", args: [["Hello"]] }, }); server.send({ type: "response", @@ -190,15 +190,15 @@ describe("Client service operations", () => { expect( server.messages.slice(2).map((message) => (message.type === "request" ? message.call : undefined)), ).toEqual([ - { serviceId: "chat", member: "prompt", args: [["text"]] }, + { serviceId: "pi.chat", member: "prompt", args: [["text"]] }, { - serviceId: "chat", + serviceId: "pi.chat", member: "prompt", args: [["image", [{ type: "image", data: "aW1n", mimeType: "image/png" }]]], }, - { serviceId: "chat", member: "prompt", args: [[{ role: "user", content: "message", timestamp: 1 }]] }, + { serviceId: "pi.chat", member: "prompt", args: [[{ role: "user", content: "message", timestamp: 1 }]] }, { - serviceId: "chat", + serviceId: "pi.chat", member: "prompt", args: [ [ @@ -236,7 +236,7 @@ describe("Client service operations", () => { await attaching; const opening = client.watchSession("session-1"); await server.waitForMessages(3); - expect(server.messages[2]).toMatchObject({ call: { serviceId: "transcript", member: "watch", args: [] } }); + expect(server.messages[2]).toMatchObject({ call: { serviceId: "pi.transcript", member: "watch", args: [] } }); server.send({ type: "response", id: "request-2", @@ -268,7 +268,7 @@ describe("Client service operations", () => { }); await server.waitForMessages(4); expect(server.messages[3]).toMatchObject({ - call: { serviceId: "transcript", member: "startWatch", args: ["watch-1"] }, + call: { serviceId: "pi.transcript", member: "startWatch", args: ["watch-1"] }, }); server.send({ type: "event", @@ -286,7 +286,7 @@ describe("Client service operations", () => { }); await server.waitForMessages(5); expect(server.messages[4]).toMatchObject({ - call: { serviceId: "transcript", member: "stopWatch", args: ["watch-1"] }, + call: { serviceId: "pi.transcript", member: "stopWatch", args: ["watch-1"] }, }); server.send({ type: "response", id: "request-4", ok: true, result: { watchId: "watch-1" } }); await Promise.resolve(); @@ -317,7 +317,7 @@ describe("Client service operations", () => { await attaching; const target = client.attachment!; const updates: string[] = []; - const opening = client.subscribeService(target, "models", "singleton", (update) => { + const opening = client.subscribeService(target, "pi.models", "singleton", (update) => { updates.push(update.type); }); await server.waitForMessages(3); @@ -327,7 +327,7 @@ describe("Client service operations", () => { call: { serviceId: "$pi.service", member: "subscribe", - args: ["service-1", "models", "singleton"], + args: ["service-1", "pi.models", "singleton"], }, }); server.send({ @@ -342,7 +342,7 @@ describe("Client service operations", () => { id: "request-2", ok: true, result: { - serviceId: "models", + serviceId: "pi.models", mode: "singleton", instances: [ { @@ -564,7 +564,7 @@ describe("Client connection lifecycle", () => { await attaching; const pending = client.promptSession("session-1", "Hello"); await first.waitForMessages(3); - expect(first.messages[2]).toMatchObject({ call: { serviceId: "chat", member: "prompt" } }); + expect(first.messages[2]).toMatchObject({ call: { serviceId: "pi.chat", member: "prompt" } }); first.disconnect(); await expect(pending).rejects.toBeInstanceOf(DisconnectedError); diff --git a/packages/coding-agent/src/experimental/client-runtime.ts b/packages/coding-agent/src/experimental/client-runtime.ts index c7358fb0b..0760103a6 100644 --- a/packages/coding-agent/src/experimental/client-runtime.ts +++ b/packages/coding-agent/src/experimental/client-runtime.ts @@ -5,30 +5,25 @@ import { createUnixTransportFactory, discoverUnixServers, type UnixServerRoute } import { isServerId } from "@earendil-works/pi-protocol"; import type { ClientCommand } from "../cli/experimental/commands/client.ts"; import { activateServer, ENV_SERVER_ID, resolveServerDirectory, resolveSessionDirectory } from "./server.ts"; -import { Chat, type ChatService } from "./services/chat.ts"; +import { Chat } from "./services/chat.ts"; import { createBuiltinServerServiceNamespace, createBuiltinSessionServiceNamespace, type ServerServices, type SessionServices, } from "./services/connection.ts"; -import { Models, type ModelsService } from "./services/models.ts"; -import { - SessionDirectory, - type SessionDirectoryService, - SessionManagement, - type SessionManagementService, -} from "./services/sessions.ts"; +import { Models } from "./services/models.ts"; +import { SessionDirectory, SessionManagement } from "./services/sessions.ts"; export interface ClientRuntimeServer { readonly route: UnixServerRoute; readonly client: Client; readonly server: ServerServices; readonly session: SessionServices; - readonly directory: SessionDirectoryService; - readonly management: SessionManagementService; - readonly models: ModelsService; - readonly chat: ChatService; + readonly directory: SessionDirectory; + readonly management: SessionManagement; + readonly models: Models; + readonly chat: Chat; } export interface ClientRuntime { diff --git a/packages/coding-agent/src/experimental/client-tui.ts b/packages/coding-agent/src/experimental/client-tui.ts index 7d18d7952..9d5153c0a 100644 --- a/packages/coding-agent/src/experimental/client-tui.ts +++ b/packages/coding-agent/src/experimental/client-tui.ts @@ -1,4 +1,4 @@ -import { BACKGROUND_CONTEXT, type RemoteState } from "@earendil-works/pi-agent-core"; +import { BACKGROUND_CONTEXT, type ReplicatedState } from "@earendil-works/pi-agent-core"; import type { SessionSummary } from "@earendil-works/pi-protocol"; import { type Component, @@ -13,15 +13,15 @@ import chalk from "chalk"; import type { ClientCommand } from "../cli/experimental/commands/client.ts"; import { type OpenClientRuntimeOptions, openClientRuntime, waitForRemoteState } from "./client-runtime.ts"; import type { SessionAttachmentState } from "./services/connection.ts"; -import type { ModelsService } from "./services/models.ts"; -import type { SessionDirectoryEvent, SessionDirectoryService, SessionManagementService } from "./services/sessions.ts"; +import type { Models } from "./services/models.ts"; +import type { SessionDirectory, SessionDirectoryEvent, SessionManagement } from "./services/sessions.ts"; export interface ClientTuiServer { readonly serverId: string; - readonly directory: SessionDirectoryService; - readonly management: Pick; - readonly attachment: RemoteState; - readonly models: ModelsService; + readonly directory: SessionDirectory; + readonly management: Pick; + readonly attachment: ReplicatedState; + readonly models: Models; } type ClientTuiAction = @@ -265,7 +265,7 @@ function findSession(server: ClientTuiServer, sessionId: string): SessionSummary return session; } -function waitForAttachment(state: RemoteState, sessionId: string): Promise { +function waitForAttachment(state: ReplicatedState, sessionId: string): Promise { if (state.value?.status === "attached" && state.value.sessionId === sessionId) return Promise.resolve(); return new Promise((resolve, reject) => { let unsubscribe: (() => void) | undefined; diff --git a/packages/coding-agent/src/experimental/services/accounts.ts b/packages/coding-agent/src/experimental/services/accounts.ts index 2ac30f605..7713ba288 100644 --- a/packages/coding-agent/src/experimental/services/accounts.ts +++ b/packages/coding-agent/src/experimental/services/accounts.ts @@ -1,4 +1,4 @@ -import { type Context, defineRemoteService, type RemoteState } from "@earendil-works/pi-agent-core"; +import { type Context, defineService, type ReplicatedState } from "@earendil-works/pi-agent-core"; export interface AccountSummary { provider: string; @@ -9,9 +9,9 @@ export interface AccountsState { providers: AccountSummary[]; } -export interface AccountsService { - readonly state: RemoteState; +export interface Accounts { + readonly state: ReplicatedState; remove(provider: string, context: Context): Promise; } -export const Accounts = defineRemoteService("accounts"); +export const Accounts = defineService("pi.accounts"); diff --git a/packages/coding-agent/src/experimental/services/chat.ts b/packages/coding-agent/src/experimental/services/chat.ts index ef167e0ff..dc3325e55 100644 --- a/packages/coding-agent/src/experimental/services/chat.ts +++ b/packages/coding-agent/src/experimental/services/chat.ts @@ -1,4 +1,4 @@ -import { type Context, defineRemoteService } from "@earendil-works/pi-agent-core"; +import { type Context, defineService } from "@earendil-works/pi-agent-core"; import type { PromptImage } from "@earendil-works/pi-protocol"; export interface ChatPromptRequest { @@ -30,7 +30,7 @@ export interface ChatNavigationRequest { customInstructions: string | null; } -export interface ChatService { +export interface Chat { prompt(request: ChatPromptRequest, context: Context): Promise; requestAbort(operationId: string, context: Context): Promise; steer(request: ChatPromptRequest, context: Context): Promise; @@ -45,4 +45,4 @@ export interface ChatService { navigate(request: ChatNavigationRequest, context: Context): Promise; } -export const Chat = defineRemoteService("chat"); +export const Chat = defineService("pi.chat"); diff --git a/packages/coding-agent/src/experimental/services/connection.ts b/packages/coding-agent/src/experimental/services/connection.ts index 56b3724df..7300d6193 100644 --- a/packages/coding-agent/src/experimental/services/connection.ts +++ b/packages/coding-agent/src/experimental/services/connection.ts @@ -7,7 +7,7 @@ import { type RemoteServiceConnection, RemoteServiceNamespace, type RemoteServiceNamespaceApi, - type RemoteState, + type ReplicatedState, remoteState, } from "@earendil-works/pi-agent-core"; import type { Client } from "@earendil-works/pi-client"; @@ -24,11 +24,11 @@ export type SessionAttachmentState = | { status: "attaching" | "attached" | "degraded"; sessionId: string }; export interface ServerServices extends RemoteServiceNamespaceApi { - readonly connection: RemoteState; + readonly connection: ReplicatedState; } export interface SessionServices extends RemoteServiceNamespaceApi { - readonly attachment: RemoteState; + readonly attachment: ReplicatedState; } export interface ServiceNamespaceOptions { @@ -37,7 +37,7 @@ export interface ServiceNamespaceOptions { } class ServerServiceNamespace extends RemoteServiceNamespace implements ServerServices { - readonly connection: RemoteState; + readonly connection: ReplicatedState; readonly #removeConnectionListener: () => void; readonly #onError: ((error: Error) => void) | undefined; #transition = Promise.resolve(); @@ -73,7 +73,7 @@ class ServerServiceNamespace extends RemoteServiceNamespace implements ServerSer } class SessionServiceNamespace extends RemoteServiceNamespace implements SessionServices { - readonly attachment: RemoteState; + readonly attachment: ReplicatedState; readonly #removeAttachmentListener: () => void; readonly #onError: ((error: Error) => void) | undefined; #transition = Promise.resolve(); diff --git a/packages/coding-agent/src/experimental/services/models.ts b/packages/coding-agent/src/experimental/services/models.ts index 41b4dabe8..7d531fe3d 100644 --- a/packages/coding-agent/src/experimental/services/models.ts +++ b/packages/coding-agent/src/experimental/services/models.ts @@ -1,4 +1,4 @@ -import { type Context, defineRemoteService, type RemoteState, type ThinkingLevel } from "@earendil-works/pi-agent-core"; +import { type Context, defineService, type ReplicatedState, type ThinkingLevel } from "@earendil-works/pi-agent-core"; export interface ModelRef { provider: string; @@ -22,11 +22,11 @@ export interface ModelsState { refresh: { status: "idle" | "refreshing" | "done" } | { status: "warning"; errors: Record }; } -export interface ModelsService { - readonly state: RemoteState; +export interface Models { + readonly state: ReplicatedState; cycleThinking(context: Context): Promise; refresh(context: Context): Promise; select(model: ModelRef, context: Context): Promise; } -export const Models = defineRemoteService("models"); +export const Models = defineService("pi.models"); diff --git a/packages/coding-agent/src/experimental/services/sessions.ts b/packages/coding-agent/src/experimental/services/sessions.ts index 76419e858..f2dffaf9f 100644 --- a/packages/coding-agent/src/experimental/services/sessions.ts +++ b/packages/coding-agent/src/experimental/services/sessions.ts @@ -1,4 +1,4 @@ -import { type Context, defineRemoteService, type RemoteEvents, type RemoteState } from "@earendil-works/pi-agent-core"; +import { type Context, defineService, type RemoteEvents, type ReplicatedState } from "@earendil-works/pi-agent-core"; import type { SessionCreateOptions, SessionSummary } from "@earendil-works/pi-protocol"; export interface SessionDirectoryState { @@ -10,18 +10,18 @@ export type SessionDirectoryEvent = | { type: "created" | "changed"; session: SessionSummary } | { type: "deleted"; sessionId: string }; -export interface SessionDirectoryService { - readonly state: RemoteState; +export interface SessionDirectory { + readonly state: ReplicatedState; readonly events: RemoteEvents; } -export const SessionDirectory = defineRemoteService("session-directory"); +export const SessionDirectory = defineService("pi.session-directory"); -export interface SessionManagementService { +export interface SessionManagement { create(options: SessionCreateOptions, context: Context): Promise; remove(sessionId: string, context: Context): Promise; attach(sessionId: string, context: Context): Promise; detach(context: Context): Promise; } -export const SessionManagement = defineRemoteService("session-management"); +export const SessionManagement = defineService("pi.session-management"); diff --git a/packages/coding-agent/src/experimental/services/transcript.ts b/packages/coding-agent/src/experimental/services/transcript.ts index ad2f3e6e2..5983078c6 100644 --- a/packages/coding-agent/src/experimental/services/transcript.ts +++ b/packages/coding-agent/src/experimental/services/transcript.ts @@ -1,4 +1,4 @@ -import { type Context, defineRemoteService, type RemoteEvents, type RemoteJson } from "@earendil-works/pi-agent-core"; +import { type Context, defineService, type RemoteEvents, type RemoteJson } from "@earendil-works/pi-agent-core"; import type { LaneEvent, LaneSnapshot } from "@earendil-works/pi-protocol"; export interface TranscriptSnapshot { @@ -12,11 +12,11 @@ export interface TranscriptEvent { event: RemoteJson; } -export interface TranscriptService { +export interface Transcript { /** Authoritative snapshot at one transcript revision. */ snapshot(context: Context): Promise; /** Ordered semantic deltas; final message events replace streamed projections authoritatively. */ readonly events: RemoteEvents; } -export const Transcript = defineRemoteService("transcript"); +export const Transcript = defineService("pi.transcript"); diff --git a/packages/coding-agent/test/experimental-chat-service.test.ts b/packages/coding-agent/test/experimental-chat-service.test.ts index 5762a27a2..f2fbb9bb3 100644 --- a/packages/coding-agent/test/experimental-chat-service.test.ts +++ b/packages/coding-agent/test/experimental-chat-service.test.ts @@ -56,18 +56,21 @@ describe("Chat service", () => { try { await expect( provider.invoke( - { serviceId: "chat", member: "prompt", args: [{ message: "hello", images: null }] }, + { serviceId: "pi.chat", member: "prompt", args: [{ message: "hello", images: null }] }, BACKGROUND_CONTEXT, ), ).resolves.toEqual({ accepted: true, operationId: "operation-1", error: null }); await expect( - provider.invoke({ serviceId: "chat", member: "requestAbort", args: ["operation-1"] }, BACKGROUND_CONTEXT), + provider.invoke( + { serviceId: "pi.chat", member: "requestAbort", args: ["operation-1"] }, + BACKGROUND_CONTEXT, + ), ).resolves.toBeUndefined(); expect(prompt).toHaveBeenCalledWith("hello", undefined, BACKGROUND_CONTEXT); expect(requestAbort).toHaveBeenCalledWith("operation-1", BACKGROUND_CONTEXT); await expect( provider.invoke( - { serviceId: "chat", member: "steer", args: [{ message: "later", images: null }] }, + { serviceId: "pi.chat", member: "steer", args: [{ message: "later", images: null }] }, BACKGROUND_CONTEXT, ), ).rejects.toBeInstanceOf(ServiceSliceNotImplemented); diff --git a/packages/coding-agent/test/experimental-remote-runtime.test.ts b/packages/coding-agent/test/experimental-remote-runtime.test.ts index 70791ddd8..6909ea59f 100644 --- a/packages/coding-agent/test/experimental-remote-runtime.test.ts +++ b/packages/coding-agent/test/experimental-remote-runtime.test.ts @@ -23,7 +23,7 @@ import { createExperimentalSessions, readExperimentalSessionState, } from "./experimental-session-support.ts"; -import { KeyedProbe, type KeyedProbeService } from "./fixtures/keyed-service.ts"; +import { KeyedProbe } from "./fixtures/keyed-service.ts"; const servers = new Set(); const clients = new Set(); @@ -449,7 +449,7 @@ describe("experimental durable server composition", () => { services: [KeyedProbe], onError: (error) => errors.push(error), }); - const observed: { service: KeyedProbeService; context: Context; value: string | undefined }[] = []; + const observed: { service: KeyedProbe; context: Context; value: string | undefined }[] = []; const stop = services.observe(KeyedProbe, (instance, context) => { observed.push({ service: instance.service, context, value: instance.service.state.value?.value }); }); diff --git a/packages/coding-agent/test/experimental-service-stubs.test.ts b/packages/coding-agent/test/experimental-service-stubs.test.ts index b1c738158..89def252a 100644 --- a/packages/coding-agent/test/experimental-service-stubs.test.ts +++ b/packages/coding-agent/test/experimental-service-stubs.test.ts @@ -5,27 +5,32 @@ import { provideBuiltinServiceStubs } from "../src/experimental/services/stubs-p describe("experimental built-in service surface", () => { test("inventories every built-in server and Session service", () => { - expect(BUILTIN_SERVER_SERVICES.map(({ id }) => id)).toEqual(["session-directory", "session-management"]); - expect(BUILTIN_SESSION_SERVICES.map(({ id }) => id)).toEqual(["accounts", "chat", "models", "transcript"]); + expect(BUILTIN_SERVER_SERVICES.map(({ id }) => id)).toEqual(["pi.session-directory", "pi.session-management"]); + expect(BUILTIN_SESSION_SERVICES.map(({ id }) => id)).toEqual([ + "pi.accounts", + "pi.chat", + "pi.models", + "pi.transcript", + ]); }); test("exposes later singleton slices as explicit unimplemented providers", async () => { const provider = new RemoteServiceProvider(BUILTIN_SESSION_SERVICES); provideBuiltinServiceStubs(provider); try { - const accounts = provider.subscribe("accounts", "singleton", () => {}).snapshot; + const accounts = provider.subscribe("pi.accounts", "singleton", () => {}).snapshot; expect(accounts.instances[0]?.states.state?.value).toEqual({ providers: [] }); await expect( - provider.invoke({ serviceId: "accounts", member: "remove", args: ["anthropic"] }, BACKGROUND_CONTEXT), + provider.invoke({ serviceId: "pi.accounts", member: "remove", args: ["anthropic"] }, BACKGROUND_CONTEXT), ).rejects.toBeInstanceOf(ServiceSliceNotImplemented); - const transcript = provider.subscribe("transcript", "singleton", () => {}).snapshot; + const transcript = provider.subscribe("pi.transcript", "singleton", () => {}).snapshot; expect(transcript.instances[0]?.members).toEqual([ { name: "events", kind: "events" }, { name: "snapshot", kind: "method" }, ]); await expect( - provider.invoke({ serviceId: "transcript", member: "snapshot", args: [] }, BACKGROUND_CONTEXT), + provider.invoke({ serviceId: "pi.transcript", member: "snapshot", args: [] }, BACKGROUND_CONTEXT), ).rejects.toBeInstanceOf(ServiceSliceNotImplemented); } finally { provider.dispose(); diff --git a/packages/coding-agent/test/fixtures/keyed-service.ts b/packages/coding-agent/test/fixtures/keyed-service.ts index a31aa158f..f2171e7c9 100644 --- a/packages/coding-agent/test/fixtures/keyed-service.ts +++ b/packages/coding-agent/test/fixtures/keyed-service.ts @@ -1,9 +1,9 @@ -import { type Context, defineRemoteService, type RemoteState } from "@earendil-works/pi-agent-core"; +import { type Context, defineService, type ReplicatedState } from "@earendil-works/pi-agent-core"; -export interface KeyedProbeService { - readonly state: RemoteState<{ value: string }>; +export interface KeyedProbe { + readonly state: ReplicatedState<{ value: string }>; replace(value: string, context: Context): Promise; wait(context: Context): Promise; } -export const KeyedProbe = defineRemoteService("test.keyed-probe"); +export const KeyedProbe = defineService("test.keyed-probe"); diff --git a/packages/coding-agent/test/fixtures/plugin-app/README.md b/packages/coding-agent/test/fixtures/plugin-app/README.md index 0867e2bba..2442c3cad 100644 --- a/packages/coding-agent/test/fixtures/plugin-app/README.md +++ b/packages/coding-agent/test/fixtures/plugin-app/README.md @@ -10,7 +10,7 @@ packages/coding-agent/test/fixtures/plugin-app/run.sh ```text lib/ Reusable plugin and transport library - api.ts Service tokens, plugins, RemoteState, RPC cancellation + api.ts Service tokens, plugins, ReplicatedState, RPC cancellation client.ts Typed client proxies and replicated state session.ts Session service host and lifecycle protocol.ts Generic JSON wire messages @@ -30,11 +30,11 @@ The library knows nothing about models, providers, commands, or TUI components. ## Plugin author model 1. Define a typed service token in `model-app/services.ts`. -2. In `plugin.session()`, create remote state and provide the service implementation. +2. In `plugin.session()`, create replicated state and provide the service implementation. 3. In `plugin.client()`, use the typed proxy and register client-local UI. 4. Add the plugin to the application's plugin list. -Remote state and ordinary method arguments/results must be JSON-serializable. `RpcOptions` are transport metadata and travel out of band. +Replicated state and ordinary method arguments/results must be JSON-serializable. `RpcOptions` are transport metadata and travel out of band. ## Disconnect contract diff --git a/packages/coding-agent/test/fixtures/plugin-app/lib/api.ts b/packages/coding-agent/test/fixtures/plugin-app/lib/api.ts index d4fffa48f..4abeab3f5 100644 --- a/packages/coding-agent/test/fixtures/plugin-app/lib/api.ts +++ b/packages/coding-agent/test/fixtures/plugin-app/lib/api.ts @@ -1,6 +1,6 @@ /** * Shared tokens and two-sided plugin shape. - * Remote state values and ordinary remote method arguments/results must be JSON-serializable. + * Replicated state values and ordinary remote method arguments/results must be JSON-serializable. * RpcOptions are recognized by the proxy and sent through the transport control plane. */ @@ -30,12 +30,12 @@ export function isRpcOptions(value: unknown): value is RpcOptions { return typeof value === "object" && value !== null && rpcOptionsMarker in value; } -export interface RemoteState { +export interface ReplicatedState { readonly value: T; subscribe(listener: (value: T) => void): () => void; } -export interface MutableRemoteState extends RemoteState { +export interface MutableReplicatedState extends ReplicatedState { set(value: T): void; } diff --git a/packages/coding-agent/test/fixtures/plugin-app/lib/client.ts b/packages/coding-agent/test/fixtures/plugin-app/lib/client.ts index f487ae2d8..75ca9e7f0 100644 --- a/packages/coding-agent/test/fixtures/plugin-app/lib/client.ts +++ b/packages/coding-agent/test/fixtures/plugin-app/lib/client.ts @@ -1,4 +1,4 @@ -import { isRpcOptions, type RemoteState, type Service } from "./api.ts"; +import { isRpcOptions, type ReplicatedState, type Service } from "./api.ts"; import type { ServerWireMessage, SessionRequest, StateSnapshot } from "./protocol.ts"; export interface ClientTransport { @@ -12,7 +12,7 @@ export type ConnectionState = | { status: "connected" } | { status: "disconnected"; reason: string }; -class ClientRemoteState implements RemoteState { +class ClientRemoteState implements ReplicatedState { private readonly listeners = new Set<(value: T) => void>(); private current: T; @@ -59,10 +59,10 @@ export class ClientStateStore { this.notify(); } - get(service: string, property: string): RemoteState { + get(service: string, property: string): ReplicatedState { const state = this.states.get(`${service}.${property}`); - if (!state) throw new Error(`Remote state not provided: ${service}.${property}`); - return state as RemoteState; + if (!state) throw new Error(`Replicated state not provided: ${service}.${property}`); + return state as ReplicatedState; } subscribe(listener: () => void): () => void { diff --git a/packages/coding-agent/test/fixtures/plugin-app/lib/session.ts b/packages/coding-agent/test/fixtures/plugin-app/lib/session.ts index a73d814ea..9c0dffca1 100644 --- a/packages/coding-agent/test/fixtures/plugin-app/lib/session.ts +++ b/packages/coding-agent/test/fixtures/plugin-app/lib/session.ts @@ -1,7 +1,7 @@ -import { type AppPlugin, type MutableRemoteState, rpcOptions, type Service } from "./api.ts"; +import { type AppPlugin, type MutableReplicatedState, rpcOptions, type Service } from "./api.ts"; import type { ServerWireMessage, SessionRequest, StateSnapshot } from "./protocol.ts"; -class SessionRemoteState implements MutableRemoteState { +class SessionRemoteState implements MutableReplicatedState { private readonly listeners = new Set<(value: T) => void>(); private current: T; @@ -55,7 +55,7 @@ export interface SessionContext { onClientDisconnect(callback: (clientId: string) => void): void; onClose(callback: () => void): void; provide(service: Service, implementation: T): void; - remoteState(initial: T): MutableRemoteState; + remoteState(initial: T): MutableReplicatedState; use(service: Service): T; } diff --git a/packages/coding-agent/test/fixtures/plugin-app/model-app/plugins.ts b/packages/coding-agent/test/fixtures/plugin-app/model-app/plugins.ts index d48f26fa7..df31797a6 100644 --- a/packages/coding-agent/test/fixtures/plugin-app/model-app/plugins.ts +++ b/packages/coding-agent/test/fixtures/plugin-app/model-app/plugins.ts @@ -1,6 +1,6 @@ import { type AppPlugin, definePlugin, defineService, type SessionContext } from "../lib/index.ts"; import { ProviderRegistry } from "./providers.ts"; -import { type ModelSpec, Models, type ModelsService, type ModelsState, type ThinkingLevel } from "./services.ts"; +import { type ModelSpec, Models, type ModelsState, type ThinkingLevel } from "./services.ts"; import { registerModelTui } from "./tui/model-selector.ts"; import type { TuiContext } from "./tui/shared.ts"; @@ -30,7 +30,7 @@ export const providersBuiltin = definePlugin({ }); const sessionController = new AbortController(); const stop = providers.subscribe((catalog) => state.set({ ...state.value, catalog })); - const models: ModelsService = { + const models: Models = { state, async cycleThinking() { const current = state.value.configuration; diff --git a/packages/coding-agent/test/fixtures/plugin-app/model-app/services.ts b/packages/coding-agent/test/fixtures/plugin-app/model-app/services.ts index 8c8d4f59d..01178ab1d 100644 --- a/packages/coding-agent/test/fixtures/plugin-app/model-app/services.ts +++ b/packages/coding-agent/test/fixtures/plugin-app/model-app/services.ts @@ -1,4 +1,4 @@ -import { defineService, type RemoteState, type RpcOptions } from "../lib/index.ts"; +import { defineService, type ReplicatedState, type RpcOptions } from "../lib/index.ts"; export type ThinkingLevel = "off" | "low" | "high"; @@ -35,11 +35,11 @@ export interface ModelsState { refresh: RefreshState; } -export interface ModelsService { - state: RemoteState; +export interface Models { + state: ReplicatedState; cycleThinking(): Promise; refresh(options?: RpcOptions): Promise; select(model: ModelRef): Promise; } -export const Models = defineService("models"); +export const Models = defineService("pi.models"); diff --git a/packages/coding-agent/test/fixtures/plugin-app/model-app/tui/app.ts b/packages/coding-agent/test/fixtures/plugin-app/model-app/tui/app.ts index 259010a2e..4ac18f388 100644 --- a/packages/coding-agent/test/fixtures/plugin-app/model-app/tui/app.ts +++ b/packages/coding-agent/test/fixtures/plugin-app/model-app/tui/app.ts @@ -12,7 +12,7 @@ import { type AppKeybinding, KeybindingsManager } from "../../../../../src/core/ import { CustomEditor } from "../../../../../src/modes/interactive/components/custom-editor.ts"; import type { SessionClient } from "../../lib/index.ts"; import type { CodingAgentPlugin } from "../plugins.ts"; -import { type LaneConfiguration, Models, type ModelsService } from "../services.ts"; +import { type LaneConfiguration, Models } from "../services.ts"; import { PluginFooter } from "./footer.ts"; import { accent, @@ -34,7 +34,7 @@ export class MinimalCodingAgentTui { private readonly commands = new Map(); private readonly editor: CustomEditor; private readonly editorContainer = new Container(); - private readonly models: ModelsService; + private readonly models: Models; private readonly renderers = new Map(); private readonly resolveDone: () => void; private readonly root: VStack; diff --git a/packages/coding-agent/test/fixtures/plugin-app/model-app/tui/footer.ts b/packages/coding-agent/test/fixtures/plugin-app/model-app/tui/footer.ts index c557d7680..a1a0f093b 100644 --- a/packages/coding-agent/test/fixtures/plugin-app/model-app/tui/footer.ts +++ b/packages/coding-agent/test/fixtures/plugin-app/model-app/tui/footer.ts @@ -1,14 +1,14 @@ import { homedir } from "node:os"; import { type Component, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; -import type { RemoteState } from "../../lib/index.ts"; +import type { ReplicatedState } from "../../lib/index.ts"; import type { ModelsState } from "../services.ts"; import { dim } from "./shared.ts"; export class PluginFooter implements Component { - private readonly models: RemoteState; + private readonly models: ReplicatedState; private readonly updateCount: () => number; - constructor(models: RemoteState, updateCount: () => number) { + constructor(models: ReplicatedState, updateCount: () => number) { this.models = models; this.updateCount = updateCount; } diff --git a/packages/coding-agent/test/fixtures/plugin-app/model-app/tui/model-selector.ts b/packages/coding-agent/test/fixtures/plugin-app/model-app/tui/model-selector.ts index d54599aac..0bfef1a5a 100644 --- a/packages/coding-agent/test/fixtures/plugin-app/model-app/tui/model-selector.ts +++ b/packages/coding-agent/test/fixtures/plugin-app/model-app/tui/model-selector.ts @@ -1,6 +1,6 @@ import { type Component, Container, type Focusable, getKeybindings, Input, Spacer, Text } from "@earendil-works/pi-tui"; import { rpcOptions } from "../../lib/index.ts"; -import type { ModelSpec, ModelsService, ModelsState } from "../services.ts"; +import type { ModelSpec, Models, ModelsState } from "../services.ts"; import { accent, border, dim, success, type TuiContext, warning } from "./shared.ts"; class Rule implements Component { @@ -10,7 +10,7 @@ class Rule implements Component { } } -export function registerModelTui(context: TuiContext, models: ModelsService): void { +export function registerModelTui(context: TuiContext, models: Models): void { let refreshController: AbortController | undefined; const resolve = (query: string): ModelSpec | undefined => { const available = models.state.value.catalog.availableModels; diff --git a/packages/coding-agent/test/plugin-app-model-vertical-slice.test.ts b/packages/coding-agent/test/plugin-app-model-vertical-slice.test.ts index a87ee4604..778396cbc 100644 --- a/packages/coding-agent/test/plugin-app-model-vertical-slice.test.ts +++ b/packages/coding-agent/test/plugin-app-model-vertical-slice.test.ts @@ -19,7 +19,7 @@ function createDeferred(): Deferred { } describe("app-as-plugins model service", () => { - it("replicates RemoteState and invokes typed methods over a serializable boundary", async () => { + it("replicates ReplicatedState and invokes typed methods over a serializable boundary", async () => { const catalogue = createDeferred(); let refreshCalls = 0; const plugins = createCodingAgentPlugins(() => { @@ -61,7 +61,7 @@ describe("app-as-plugins model service", () => { }); expect(runtime.driver.use(Settings).get("defaultModel")).toEqual({ provider: "acme", modelId: "fresh" }); expect(observed).toEqual(["idle", "refreshing", "refreshing", "done", "done"]); - expect(runtime.driver.trace).toEqual(["rpc:models.refresh", "rpc:models.select"]); + expect(runtime.driver.trace).toEqual(["rpc:pi.models.refresh", "rpc:pi.models.select"]); unsubscribe(); client.close(); runtime.close(); diff --git a/packages/protocol/src/protocol.ts b/packages/protocol/src/protocol.ts index e9e06e4a9..275513118 100644 --- a/packages/protocol/src/protocol.ts +++ b/packages/protocol/src/protocol.ts @@ -209,13 +209,13 @@ export function decodeServiceControlCall(call: ProtocolRpcCall): ServiceControlC } const ServiceRpcAddresses = { - list: { serviceId: "session-directory", member: "list" }, - create: { serviceId: "session-management", member: "create" }, - attach: { serviceId: "session-management", member: "attach" }, - prompt: { serviceId: "chat", member: "prompt" }, - watch: { serviceId: "transcript", member: "watch" }, - startWatch: { serviceId: "transcript", member: "startWatch" }, - stopWatch: { serviceId: "transcript", member: "stopWatch" }, + list: { serviceId: "pi.session-directory", member: "list" }, + create: { serviceId: "pi.session-management", member: "create" }, + attach: { serviceId: "pi.session-management", member: "attach" }, + prompt: { serviceId: "pi.chat", member: "prompt" }, + watch: { serviceId: "pi.transcript", member: "watch" }, + startWatch: { serviceId: "pi.transcript", member: "startWatch" }, + stopWatch: { serviceId: "pi.transcript", member: "stopWatch" }, } as const satisfies Record; /** Translate the current built-in contract to its generic service envelope. */ diff --git a/packages/protocol/test/protocol.test.ts b/packages/protocol/test/protocol.test.ts index 0fdef2a75..a3dc65028 100644 --- a/packages/protocol/test/protocol.test.ts +++ b/packages/protocol/test/protocol.test.ts @@ -112,12 +112,12 @@ describe("RPC manifest", () => { test("maps built-in contracts onto generic service/member envelopes", () => { const encoded = encodeServiceRpcCall({ method: "attach", args: ["session-1"] }); expect(encoded).toEqual({ - serviceId: "session-management", + serviceId: "pi.session-management", member: "attach", args: ["session-1"], }); expect(decodeServiceRpcCall(encoded)).toEqual({ method: "attach", args: ["session-1"] }); - expect(decodeServiceRpcCall({ serviceId: "session-management", member: "attach", args: [] })).toBeUndefined(); + expect(decodeServiceRpcCall({ serviceId: "pi.session-management", member: "attach", args: [] })).toBeUndefined(); expect(decodeServiceRpcCall({ serviceId: "unknown", member: "method", args: [] })).toBeUndefined(); }); @@ -209,11 +209,11 @@ describe("protocol validation", () => { }); test("encodes service control and keyed instance addresses", () => { - const subscribe = createServiceSubscribeCall("subscription-1", "models", "singleton"); + const subscribe = createServiceSubscribeCall("subscription-1", "pi.models", "singleton"); expect(decodeServiceControlCall(subscribe)).toEqual({ type: "subscribe", subscriptionId: "subscription-1", - serviceId: "models", + serviceId: "pi.models", mode: "singleton", }); expect(decodeServiceControlCall(createServiceUnsubscribeCall("subscription-1"))).toEqual({ @@ -222,12 +222,12 @@ describe("protocol validation", () => { }); expect( parseServiceSubscriptionSnapshot({ - serviceId: "transcript", + serviceId: "pi.transcript", mode: "singleton", instances: [{ members: [{ name: "events", kind: "events" }], states: {} }], }), ).toEqual({ - serviceId: "transcript", + serviceId: "pi.transcript", mode: "singleton", instances: [{ members: [{ name: "events", kind: "events" }], states: {} }], }); @@ -251,7 +251,7 @@ describe("protocol validation", () => { attachmentId: "attachment-1", }, call: { - serviceId: "question-dialog", + serviceId: "pi.question-dialog", instance: { key: "invocation-1", generation: 2 }, member: "submit", args: [{ outcome: "selected", index: 0 }], @@ -265,19 +265,19 @@ describe("protocol validation", () => { type: "request", id: "request-1", target: { serverId: "00000000-0000-4000-8000-000000000001" }, - call: { serviceId: "session-directory", member: "list", args: [] }, + call: { serviceId: "pi.session-directory", member: "list", args: [] }, }; const create: ClientMessage = { type: "request", id: "request-2", target: { serverId: "00000000-0000-4000-8000-000000000001" }, - call: { serviceId: "session-management", member: "create", args: [{}] }, + call: { serviceId: "pi.session-management", member: "create", args: [{}] }, }; const attach: ClientMessage = { type: "request", id: "request-3", target: { serverId: "00000000-0000-4000-8000-000000000001" }, - call: { serviceId: "session-management", member: "attach", args: ["session-1"] }, + call: { serviceId: "pi.session-management", member: "attach", args: ["session-1"] }, }; const prompt: ClientMessage = { type: "request", @@ -287,17 +287,17 @@ describe("protocol validation", () => { sessionId: "session-1", attachmentId: "attachment-1", }, - call: { serviceId: "chat", member: "prompt", args: [["Hello"]] }, + call: { serviceId: "pi.chat", member: "prompt", args: [["Hello"]] }, }; expect(parseClientMessage(list)).toEqual(list); expect(parseClientMessage(create)).toEqual(create); expect(parseClientMessage(attach)).toEqual(attach); expect(parseClientMessage(prompt)).toEqual(prompt); expect(parseClientMessage({ ...create, call: { ...create.call, args: [{ cwd: "" }] } })).toMatchObject({ - call: { serviceId: "session-management", member: "create" }, + call: { serviceId: "pi.session-management", member: "create" }, }); expect(parseClientMessage({ ...attach, call: { ...attach.call, args: [] } })).toMatchObject({ - call: { serviceId: "session-management", member: "attach", args: [] }, + call: { serviceId: "pi.session-management", member: "attach", args: [] }, }); expect( parseClientMessage({ @@ -306,7 +306,7 @@ describe("protocol validation", () => { }), ).toMatchObject({ call: { serviceId: "unknown", member: "method" } }); expect(parseClientMessage({ ...prompt, call: { ...prompt.call, args: ["session-1", []] } })).toMatchObject({ - call: { serviceId: "chat", member: "prompt" }, + call: { serviceId: "pi.chat", member: "prompt" }, }); }); @@ -618,7 +618,7 @@ describe("validated framed protocol APIs", () => { type: "request", id: "request-1", target: { serverId: "00000000-0000-4000-8000-000000000001" }, - call: { serviceId: "session-directory", member: "list", args: [] }, + call: { serviceId: "pi.session-directory", member: "list", args: [] }, }; const first = encodeClientMessage(clientHello); const second = encodeClientMessage(request); diff --git a/packages/server/src/testing/client.ts b/packages/server/src/testing/client.ts index 6e0a8b727..745ce4bdf 100644 --- a/packages/server/src/testing/client.ts +++ b/packages/server/src/testing/client.ts @@ -166,16 +166,16 @@ export class ProtocolTestClient { function serviceAddress(method: string): { serviceId: string; member: string } { switch (method) { case "list": - return { serviceId: "session-directory", member: "list" }; + return { serviceId: "pi.session-directory", member: "list" }; case "create": case "attach": - return { serviceId: "session-management", member: method }; + return { serviceId: "pi.session-management", member: method }; case "prompt": - return { serviceId: "chat", member: "prompt" }; + return { serviceId: "pi.chat", member: "prompt" }; case "watch": case "startWatch": case "stopWatch": - return { serviceId: "transcript", member: method }; + return { serviceId: "pi.transcript", member: method }; default: return { serviceId: "unknown", member: method }; } diff --git a/packages/server/test/conformance.test.ts b/packages/server/test/conformance.test.ts index 5a7765efc..ab7efd27d 100644 --- a/packages/server/test/conformance.test.ts +++ b/packages/server/test/conformance.test.ts @@ -181,7 +181,7 @@ describe("Session protocol", () => { attachClient(presentation) { return { async invokeService(call, _publish, context) { - if (call.serviceId !== "session-management") throw new Error("Unexpected service"); + if (call.serviceId !== "pi.session-management") throw new Error("Unexpected service"); if (call.member === "attach" && typeof call.args[0] === "string") { await presentation.attachSession(call.args[0], context); return undefined; @@ -208,7 +208,7 @@ describe("Session protocol", () => { type: "request", id: "service-attach", target: { serverId: "00000000-0000-4000-8000-000000000001" }, - call: { serviceId: "session-management", member: "attach", args: ["session-1"] }, + call: { serviceId: "pi.session-management", member: "attach", args: ["session-1"] }, }); await expect(attached).resolves.toMatchObject({ type: "attachment", @@ -227,7 +227,7 @@ describe("Session protocol", () => { type: "request", id: "service-detach", target: { serverId: "00000000-0000-4000-8000-000000000001" }, - call: { serviceId: "session-management", member: "detach", args: [] }, + call: { serviceId: "pi.session-management", member: "detach", args: [] }, }); await expect(detached).resolves.toMatchObject({ type: "attachment", attachment: null }); await expect(detachResponse).resolves.toEqual({ @@ -451,7 +451,7 @@ describe("Session protocol", () => { type: "request", id: "stale-request", target: { serverId, sessionId: "session-1", attachmentId: first.result.attachmentId }, - call: { serviceId: "chat", member: "prompt", args: [["stale"]] }, + call: { serviceId: "pi.chat", member: "prompt", args: [["stale"]] }, }); await expect(response).resolves.toMatchObject({ type: "response", diff --git a/packages/server/test/protocol.test.ts b/packages/server/test/protocol.test.ts index c6aa7b893..7dfba1f9a 100644 --- a/packages/server/test/protocol.test.ts +++ b/packages/server/test/protocol.test.ts @@ -54,7 +54,7 @@ test("requires hello as the first message", async () => { type: "request", id: "request-1", target: { serverId: "00000000-0000-4000-8000-000000000001" }, - call: { serviceId: "session-directory", member: "list", args: [] }, + call: { serviceId: "pi.session-directory", member: "list", args: [] }, }); await expect(client.next((message) => message.type === "hello_error")).resolves.toMatchObject({ type: "hello_error", @@ -87,7 +87,7 @@ test("accepts fragmented hello and request frames", async () => { type: "request" as const, id: "request-1", target: { serverId: "00000000-0000-4000-8000-000000000001" }, - call: { serviceId: "session-directory" as const, member: "list" as const, args: [] as [] }, + call: { serviceId: "pi.session-directory" as const, member: "list" as const, args: [] as [] }, }; const frame = encodeClientMessage(request); await client.sendFragmentedMessage(request, Math.floor(frame.byteLength / 2)); @@ -126,7 +126,7 @@ test("processes a hello and request coalesced in one byte chunk", async () => { type: "request", id: "request-1", target: { serverId: "00000000-0000-4000-8000-000000000001" }, - call: { serviceId: "session-directory", member: "list", args: [] }, + call: { serviceId: "pi.session-directory", member: "list", args: [] }, }); const wire = new Uint8Array(hello.byteLength + request.byteLength); wire.set(hello);