diff --git a/packages/agent/docs/plugins.md b/packages/agent/docs/plugins.md index d9554ac3a..3fff62c91 100644 --- a/packages/agent/docs/plugins.md +++ b/packages/agent/docs/plugins.md @@ -9,36 +9,33 @@ This document assumes you already understand `AgentHarness`, `AgentLane`, `Sessi The new coding agent is assembled from an ordered plugin manifest, not a monolith with an extension API bolted on. Three layers: 1. The **plugin kernel** owns generic lifecycle mechanics: ordered setup, activation, scoped resource ownership, failure rollback, and reverse-order disposal. It knows nothing about Harness, tools, TUI components, RPC services, connections, or coding-agent policy. -2. An **application host** owns one concrete runtime and constructs the plugin context for that runtime. The **session host** runs in a session process and owns session authority — the real Harness. A **presentation host** (TUI today, web later) owns a user interface. A **coordinator host** runs in a coordinator process and owns fleet authority: session records (`SessionRepo`), session process management, and all routing between presentations and sessions. -3. A **plugin package** implements one feature as one or more host-specific **facets** — setup functions, one per host kind. Each facet receives only the context for its host. +2. An **application host** owns one concrete runtime and constructs the plugin bindings for that runtime. The **session host** normally runs in a dedicated session worker and owns session authority — the real Harness. A **presentation host** (TUI today, web later) owns a user interface. A **server host** owns server-wide authority: session records (`SessionRepo`), session-worker management, authentication, attachment, and routing between presentations and session workers. +3. A **plugin package** implements one feature as one or more host-specific **facets** — setup functions, one per host kind. Each facet receives only the bindings natural to its host instance and process. -Coordinators form a strict **management tree**. Every other process connects to exactly one coordinator: +The initial topology has one server and no server-to-server links: ```text - C0 (root coordinator) - ├─ TUI A presentation client of C0 - ├─ S0 session process managed by C0 - ├─ C1 (child coordinator) - │ ├─ TUI B presentation client of C1 - │ └─ S1 session managed by C1 - └─ C2 (child coordinator) - └─ S2 session managed by C2 +server +├─ TUI A +├─ web B +├─ session worker S0 +└─ session worker S1 ``` -Connection rules: a session connects only to the coordinator that manages it; a presentation connects to one coordinator; a child coordinator connects to its parent while accepting its own presentations, sessions, and children; the root has no parent. There is **no direct presentation→session connection** — all session traffic passes through coordinators. +A presentation and a session worker each connect to the server. There is no direct presentation→session-worker connection; the server routes service calls to the selected worker. The server lists and manages only its own sessions. Multi-server routing and server hierarchies are out of scope. -A coordinator exposes **every session in the subtree it manages**. TUI A, connected to C0, can list and attach to S0, S1, and S2. TUI B, connected to C1, sees only C1's subtree — S1. Visibility never expands upward: connecting to a child never reveals the parent's or a sibling's sessions. Each coordinator needs only one routing map, `sessionId → local session connection or child coordinator connection`. A child reports a snapshot of its managed session records plus change events to its parent, and forwards any request its parent sends for a session it manages; the parent merges local and child records. +A session worker normally owns one session, and each session facet is instantiated for that session. A server facet is instantiated once per server process and is shared across every session and presentation connected to it. Server facets should therefore be rare and limited to inherently server-wide concerns. Per-session feature state belongs in session facets; dedicated workers provide the preferred lifecycle, crash, and state isolation. Future co-location may preserve the same logical bindings without changing what objects a facet can access. -**Host** and **client** are roles per connection, not fixed process kinds. A coordinator is host to its presentations, sessions, and children, and client of its parent. The session↔coordinator connection carries calls in both directions: the session serves its exposed services and also consumes coordinator services. "Client" below always names the connection role, never a kind of plugin. +**Host** and **client** are roles per connection, not fixed process kinds. The server hosts presentation and session-worker connections. A session worker serves its provided session services and may consume server-provided services over the same RPC binding mechanism. "Client" below always names the connection role, never a kind of plugin. ## Why this shape -- **Authority stays where it belongs.** Provider credentials and tool execution exist only in the session process; session records and process control only in coordinators. Nothing reaches a presentation except through a deliberate contract. +- **Authority stays where it belongs.** Provider credentials, tool execution, and per-session plugin data exist only in the session worker; session records and worker control exist only in the server. Nothing reaches a presentation except through a deliberate contract. - **One feature stays coherent.** The question plugin's tool, dialog, and renderer ship in one package around one JSON contract, yet each facet is host-native code. -- **A new surface is presentation-only work.** A web facet for the question dialog or the session picker registers against existing tokens; session and coordinator code do not change. -- **Visibility scales with the tree.** Connect a management UI to the root to see everything; connect a workstation TUI to a leaf coordinator to see only its sessions. -- **No privileged built-ins.** Built-ins and third-party plugins receive identical contexts, so shipping the product continuously exercises the extension API. -- **Testable in pieces.** A facet tests against its host context, a contract against loopback, and a forwarded TUI → coordinator → session path against a real transport — independently. +- **A new surface is presentation-only work.** A web facet for the question dialog or the session picker registers against existing tokens; session and server code do not change. +- **Server state stays server-wide.** A server facet is shared by all sessions and clients, so features use one only when their authority is inherently global to that server. +- **No privileged built-ins.** Built-ins and third-party plugins receive identical bindings, so shipping the product continuously exercises the extension API. +- **Testable in pieces.** A facet tests against its host bindings, a contract against loopback, and a routed TUI → server → session-worker path against a real transport — independently. ## One feature, several host facets @@ -47,7 +44,7 @@ A coding-agent plugin is a feature bundle that may provide a facet for any host: ```ts interface CodingAgentPlugin { readonly id: string; - readonly coordinator?: PluginFacet; + readonly server?: PluginFacet; readonly session?: PluginFacet; readonly tui?: PluginFacet; readonly web?: PluginFacet; @@ -56,14 +53,14 @@ interface CodingAgentPlugin { type PluginFacet = (context: PluginContext) => void | Promise; ``` -`PluginFacet` is the only plugin-facing shape the generic kernel needs; the facet names and context types belong to the coding-agent application. Facets are optional: a `models.json` plugin has only a session facet, a terminal theme only a TUI facet, the session directory a coordinator facet plus a TUI facet. Examples use an illustrative `definePlugin()` helper returning a `CodingAgentPlugin` — the loaded, in-process shape. +`PluginFacet` is the only plugin-facing shape the generic kernel needs; the facet names and context types belong to the coding-agent application. Facets are optional: a `models.json` plugin has only a session facet, a terminal theme only a TUI facet, the session directory a server facet plus a TUI facet. Examples use an illustrative `definePlugin()` helper returning a `CodingAgentPlugin` — the loaded, in-process shape. A package keeps shared wire contracts separate from host dependencies: ```text question-plugin/ - contract.ts JSON DTOs and semantic service/interaction tokens - session.ts tool contribution; imports agent/session code + contract.ts JSON DTOs and service tokens + session.ts pending-question authority and tool contribution; imports agent/session code tui.ts terminal dialog and renderer; imports TUI code web.ts optional browser dialog and renderer index.ts loopback bundle; production hosts resolve per-host entry points @@ -74,24 +71,34 @@ The browser build never imports `session.ts`; the session process never imports The question plugin is this document's end-to-end example: ```text -model calls the question tool (session facet) -→ session facet requests QuestionInteraction (shared contract) -→ TUI facet presents a dialog, returns JSON (TUI host context) +model calls the question tool (session facet) +→ session facet records one pending question (session authority) +→ every connected TUI/web facet observes and opens it (shared service) +→ the first accepted answer settles it for everyone → session facet returns the durable tool result -→ TUI facet renders it with its contributed renderer +→ presentation facets close the dialog and render the result ``` -The models service in the next sections illustrates services and replicated state; the [coordinator section](#the-coordinator-directory-management-and-forwarding) covers fleet services and forwarding; the [question section](#reverse-interactions-the-question-plugin) makes the full round trip concrete. +With no presentation connected, the question remains pending. A TUI or web facet that connects later obtains the same pending question. + +The models service in the next sections illustrates services and replicated state; the [server section](#the-server-directory-management-and-routing) covers server-wide services and session routing; the [question section](#session-owned-deferred-interactions-the-question-plugin) makes the full round trip concrete. ## Starting and connecting hosts -Every host receives the same **logical manifest**: JSON-safe plugin identity, order, and version. It resolves only its own package entry points (`./coordinator`, `./session`, `./tui`, or `./web`); a missing entry point means that plugin has no facet for the host. +Every host receives the same **logical manifest**: JSON-safe plugin identity, order, and version. It resolves only its own package entry points (`./server`, `./session`, `./tui`, or `./web`); a missing entry point means that plugin has no facet for the host. Resolution, not convention, enforces the dependency boundary. -Hosts do not discover coordinators or open sockets themselves. They receive one **connection function**: +```ts +const manifest: PluginManifestEntry[] = [ + { id: "@pi/session-core" }, + { id: "@pi/providers-builtin" }, + { id: "@pi/question" }, +]; +``` + +Presentation and session-worker hosts do not discover servers or open sockets themselves. They receive one connection function; the server host receives a listener: ```ts type Connect = (signal: AbortSignal) => Promise; - type Listen = (accept: (transport: Transport) => void) => () => void; interface Transport { @@ -102,7 +109,7 @@ interface Transport { } ``` -`Connect` performs one connection attempt. Its closure owns the endpoint and credential, so the host never reads an address, environment variable, or secret. `Listen` lets a coordinator accept presentations, sessions, and child coordinators. Both operate on the same bidirectional framed `Transport`. +`Connect` performs one connection attempt. Its closure owns the endpoint and credential, so the host never reads an address, environment variable, or secret. `Listen` lets the server accept presentation and session-worker connections. Both operate on the same bidirectional framed `Transport`. ```ts declare const tuiAppHost: { @@ -113,12 +120,8 @@ declare const sessionAppHost: { start(options: { manifest: Manifest; connect: Connect; identity: SessionIdentity }): Promise; }; -declare const coordinatorAppHost: { - start(options: { - manifest: Manifest; - listen: Listen; - connect?: Connect; // parent coordinator; omitted at the root - }): Promise; +declare const serverAppHost: { + start(options: { manifest: Manifest; listen: Listen }): Promise; }; interface RunningHost { @@ -126,8 +129,7 @@ interface RunningHost { stop(): Promise; } -interface RunningCoordinator { - readonly parentConnection: RemoteState | null; +interface RunningServer { readonly local: Connect; // connect another host in the same process stop(): Promise; } @@ -137,27 +139,21 @@ Every deployment uses those three entry points: ```ts // separate processes -await tuiAppHost.start({ manifest, connect: dialSocket(endpoint, token) }); +await serverAppHost.start({ manifest, listen: listenSocket(socketPath) }); await sessionAppHost.start({ manifest, identity, connect: dialSocket(endpoint, token) }); -await coordinatorAppHost.start({ manifest, listen: listenSocket(socketPath) }); // root -await coordinatorAppHost.start({ - manifest, - listen: listenSocket(childSocketPath), - connect: dialTls(parentUrl, certificate), -}); // child +await tuiAppHost.start({ manifest, connect: dialSocket(endpoint, token) }); // standalone: the same hosts and boundaries, without sockets -const coordinator = await coordinatorAppHost.start({ manifest, listen: listenNothing() }); -await sessionAppHost.start({ manifest, identity, connect: coordinator.local }); -await tuiAppHost.start({ manifest, connect: coordinator.local }); +const server = await serverAppHost.start({ manifest, listen: listenNothing() }); +await sessionAppHost.start({ manifest, identity, connect: server.local }); +await tuiAppHost.start({ manifest, connect: server.local }); ``` This keeps deployment policy outside plugins: - reconnecting means calling `connect(signal)` again; the host owns retry timing, while `Connect` owns one attempt; -- the accepting coordinator assigns peer identity during its handshake from the presented credential; `SessionIdentity` identifies the durable session being run, not a trusted claim about the connection; -- `coordinator.local` makes standalone a deployment, not a separate mode or code path; -- a child coordinator's parent link uses the same `Connect` shape as every other connection. +- the accepting server assigns peer identity during its handshake from the presented credential; `SessionIdentity` identifies the durable session being run, not a trusted claim about the connection; +- `server.local` makes standalone a deployment, not a separate mode or code path. The only connection state exposed to facets is: @@ -168,7 +164,7 @@ type ConnectionState = | { status: "disconnected"; since: string; reason: string; retryAt: string | null }; ``` -After connecting, each host asks the kernel to create one lifecycle scope per selected facet, builds its host-specific context, and invokes the facet: +After transport setup, each host asks the kernel to create one lifecycle scope per selected facet, builds its host-specific bindings, and invokes the facet: ```ts interface PluginLifecycleScope { @@ -178,9 +174,11 @@ interface PluginLifecycleScope { } ``` -Host infrastructure calls `own()` for every service, contribution, or subscription registered through a facet context. Facets first register in manifest order; then `onActivate` callbacks start effects and the host exposes state snapshots. Failure and normal shutdown dispose scopes in reverse order. Duplicate services, missing dependencies, and dependency cycles are application-assembly errors. +Host infrastructure calls `own()` for every service, contribution, or subscription registered through facet bindings. Facets first register in manifest order; then `onActivate` callbacks start effects and the host exposes state snapshots. Failure and normal shutdown dispose scopes in reverse order. Duplicate services, missing dependencies, and dependency cycles are application-assembly errors. -The prototype in `packages/coding-agent/test/fixtures/plugin-app/` demonstrates this composition with an ad hoc transport. `rpc.md` should replace that transport without changing the host/facet model. Plugins never open sockets or implement request IDs, trace carriers, cancellation frames, references, or reconnect buffering. +The prototype in `packages/coding-agent/test/fixtures/plugin-app/` demonstrates the composition model with an ad hoc RPC implementation; the shared RPC design in `rpc.md` should replace that transport without replacing the host/facet architecture. + +One process owns one session, and session authority never migrates. Each presentation and session worker has one multiplexed connection to its server; plugins never open private sockets. Plugin authors never implement request IDs, sockets, trace carriers, cancellation frames, remote-reference registries, or reconnect buffering. Out of scope: arbitrary object remoting, serialized functions/classes/`Map`/`Set`, remote hook or tool execution, offline presentation writes or automatic mutation replay, a universal remote `AgentHarness` or serialized UI tree, and re-exposing forwarded frames, proxies, or references as plugin-visible objects. @@ -283,7 +281,7 @@ A service has **one owner and many consumers**: `providersBuiltin` provides `Mod - **Local:** in the process that provides the service, `use()` is synchronous and returns the actual implementation object. No serialization, no proxy. - **Remote:** across a connection, `use()` is asynchronous and demand-driven: it subscribes to the service on first demand and resolves a typed proxy. Concurrent consumers of one token in one process share one proxy, one state replica, and one remote subscription. -Presentation facets consume remotely through two explicit namespaces — `coordinator.use()` and `session.use()`, defined below. Session facets consume their own process's services locally and coordinator services remotely through `coordinator.use()`. `provide()`, `use()`, and `remoteState()` are host infrastructure layered over the kernel scope, not kernel API. +Presentation facets consume remotely through two explicit namespaces — `server.use()` and `session.use()`, defined below. Session facets consume their own process's services locally and server services remotely through `server.use()`. `provide()`, `use()`, and `remoteState()` are host infrastructure layered over the kernel scope, not kernel API. ## What each host context grants @@ -310,7 +308,7 @@ interface CodingAgentSessionPluginContext extends PluginLifecycleScope { remoteState(initial: T): MutableRemoteState; remoteEvents(): MutableRemoteEvents; - readonly coordinator: CoordinatorServices; // remote access to the managing coordinator + readonly server: ServerServices; // remote access to the managing server // contribution registries (session-host owned) readonly providers: ProviderContributionRegistry; @@ -325,11 +323,11 @@ interface CodingAgentSessionPluginContext extends PluginLifecycleScope { "Local" and "unrestricted" are separate decisions. The scope narrows authority for lifecycle and composition — hooks and event subscriptions registered through it are automatically owned by the plugin and disposed with it — but `sessionTree` may be the actual local derived object. The host keeps the unrestricted concrete instances and reserves: `AgentHarness.close()` and `Session.close()`; raw `Session.mutate()` and `SessionMutator` (unless a narrowly trusted durability plugin explicitly owns them); `idGenerator` and backend/storage objects; whole-registry setters such as `setTools()`; unscoped hook/event registration; transport exposure and remote-reference registration. This is a composition and lifecycle boundary, not a security sandbox: session facets are trusted code in the authoritative process. The manifest may explicitly grant broader local capability, but built-ins receive no implicit bypass. -**Presentation facets hold none of this.** A TUI or web facet never receives the raw Harness, Session, tree, tool registry, hooks, or credentials — not even as proxies. It receives only what a session or coordinator facet deliberately exposes: semantic service proxies, replicated state, and semantic events/interactions. +**Presentation facets hold none of this.** A TUI or web facet never receives the raw Harness, Session, tree, tool registry, hooks, or credentials — not even as proxies. It receives only what a session or server facet deliberately exposes: semantic service proxies, replicated state, and semantic events/interactions. ```ts -interface CoordinatorServices { - use(service: RemoteService): Promise; // terminates at the connected coordinator +interface ServerServices { + use(service: RemoteService): Promise; // terminates at the connected server readonly connection: RemoteState; } @@ -341,7 +339,7 @@ interface SessionServices { } interface CodingAgentTuiPluginContext extends PluginLifecycleScope { - readonly coordinator: CoordinatorServices; // two explicit remote namespaces + readonly server: ServerServices; // two explicit remote namespaces readonly session: SessionServices; readonly interactions: PresentationInteractions; @@ -354,9 +352,9 @@ interface CodingAgentTuiPluginContext extends PluginLifecycleScope { } ``` -The two namespaces are the topology made visible: `coordinator.use(SessionDirectory)` resolves a service the connected coordinator provides itself; `session.use(Models)` resolves a service of the one **selected session** — the session this presentation is currently attached to. A presentation has at most one selected session. `session.use()` resolves its shared proxy immediately; while detached, calls fail with `not_attached` and no state is present. The two namespaces fail independently: the coordinator connection can be healthy while the selected session is unreachable (`attachment.status === "degraded"`). +The two namespaces are the topology made visible: `server.use(SessionDirectory)` resolves a service the connected server provides itself; `session.use(Models)` resolves a service of the one **selected session** — the session this presentation is currently attached to. A presentation has at most one selected session. `session.use()` resolves its shared proxy immediately; while detached, calls fail with `not_attached` and no state is present. The two namespaces fail independently: the server connection can be healthy while the selected session is unreachable (`attachment.status === "degraded"`). -A future `CodingAgentWebPluginContext` carries the same two namespaces plus browser registries — routes, views, DOM dialogs. The coordinator context is defined in the [coordinator section](#the-coordinator-directory-management-and-forwarding). Contexts resemble each other by convention; no kernel-owned base type forces the shape. +A future `CodingAgentWebPluginContext` carries the same two namespaces plus browser registries — routes, views, DOM dialogs. The server context is defined in the [server section](#the-server-directory-management-and-routing). Contexts resemble each other by convention; no kernel-owned base type forces the shape. Concretely, a chat plugin exposes a narrow `Chat` contract — `prompt(request, context)` returning `{ accepted, operationId, error }`, plus `requestAbort(operationId, context)` — and implements it with direct local lane capabilities: @@ -409,12 +407,15 @@ interface MutableRemoteState extends RemoteState { Required behavior: -1. The providing host owns the one authoritative value; there are no remote writes. -2. **Hydration** — installing a complete snapshot of all exposed state values atomically before updates flow — happens when a consumer first subscribes; updates emitted meanwhile are buffered, so a client observes snapshot-then-updates with no gap. -3. After hydration, client `.value` is synchronously readable and `subscribe()` immediately reports the current value, then future updates. The immediate callback runs under a fresh **hydration context** parented to the subscription, because the write that produced the value may be long settled. -4. Values are detached with structured JSON semantics; one listener cannot mutate another's state. -5. Reconnect or reattach replaces client state from a fresh authoritative snapshot; disconnect retains the last value as stale display data alongside the connection/attachment state. -6. `set(value, context)` carries source trace metadata; each delivery invokes listeners with a reconstructed delivery `Context`. Background updates use an intentional background or lifecycle context, never a retained caller context. +1. The providing host owns the one authoritative value; remote consumers call methods rather than writing the replica. +2. Every replica has a local initial value. A facility may declare one; otherwise it is JSON `null`. `subscribe()` immediately reports that current local value even before hydration, keeping local and remote subscription behavior consistent. The state type must include `null` when no non-null initial value is declared. +3. **Hydration** — installing a complete snapshot of all exposed state values atomically before updates flow — happens when a consumer first subscribes; subscribing before hydration is valid, and updates emitted meanwhile are buffered, so a client observes initial-value, snapshot, then updates with no gap. +4. The client replica retains its latest local value. After hydration, client `.value` is synchronously readable and `subscribe()` immediately reports the current value, then future updates. The immediate pre-hydration callback uses a local lifecycle context; a hydrated snapshot callback uses a fresh **hydration context** parented to the subscription, because the write that produced the value may be long settled. +5. Values are detached with structured JSON semantics; one listener cannot mutate another's state. +6. Reconnect or reattach replaces client state from a fresh authoritative snapshot; disconnect retains the last value as stale display data alongside the connection/attachment state. +7. `set(value, context)` carries source trace metadata; each 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. 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. @@ -495,7 +496,7 @@ interface GitService { const Git = defineRemoteService("git", { state: ["status"], events: ["events"] }); ``` -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`). Critical resumable streams need stable event IDs and a replay policy; passive UI invalidation can re-hydrate from a fresh snapshot after reconnect. +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. ## Service-owned jobs @@ -511,27 +512,27 @@ interface IndexJob { An `IndexService.start(root, context)` returning an `IndexJob` validates the root, creates its own `AbortController` and a detached telemetry root, and returns the job. The job crosses the wire as a **remote object reference** (`rpc.md`) — an opaque ID the client proxy holds. It makes the cancellation domains concrete: `wait()` abort is invocation cancellation; `cancel()` is service-owned. The exposure registry must release completed references under an explicit lifetime policy. -## The coordinator: directory, management, and forwarding +## The server: directory, management, and routing -The coordinator host does two jobs. It **owns fleet services** — listing, creating, deleting, attaching to sessions — implemented by coordinator facets against scoped local authority. And it **forwards session traffic** between attached presentations and session processes — pure host infrastructure that no plugin code touches. +The server host does two jobs. It **owns server-wide services**—listing, creating, deleting, and attaching to sessions—and it **routes session traffic** between attached presentations and the session workers it manages. Routing is host infrastructure that plugin code does not implement. -### Coordinator host context +A server facet is shared by every session and presentation connected to the server. It should be used only for inherently server-wide features. Per-session feature data belongs in session facets. + +### Server host context ```ts interface FleetPluginScope { - readonly records: SessionRecordsView; // scoped SessionRepo view: locally managed sessions - readonly processes: SessionProcessesView; // scoped process management: locally managed sessions - readonly managed: ManagedSessionsView; // merged records: local + all child coordinators - readonly attachments: AttachmentsView; // bind/unbind a client's selected session + readonly managed: ManagedSessionsView; // sessions managed by this server + readonly attachments: AttachmentsView; // bind/unbind a client's selected session } -interface CodingAgentCoordinatorPluginContext extends PluginLifecycleScope { +interface CodingAgentServerPluginContext extends PluginLifecycleScope { readonly fleet: FleetPluginScope; // plus the same provide/use/remoteState/remoteEvents as the session host context } ``` -The reserved list mirrors the session host: the raw `SessionRepo`, storage handles, unrestricted process-kill authority, the routing map, and forwarding machinery stay with the coordinator application. `fleet.managed` is the host-maintained merge of locally managed records and every child coordinator's reported records — the host consumes each child's snapshot and change events over that child's connection and keeps the merge current: +The raw `SessionRepo`, storage handles, unrestricted process-kill authority, routing map, and routing machinery stay with the server application: ```ts interface ManagedSessionRecord { @@ -539,7 +540,7 @@ interface ManagedSessionRecord { title: string; workspaceId: string; ownerId: string; - cwd: string; // ownerId and cwd never leave the coordinator + cwd: string; // ownerId and cwd never leave the server status: "starting" | "active" | "idle" | "closed" | "unreachable"; updatedAt: string; } @@ -550,13 +551,13 @@ interface ManagedSessionsView { snapshot(): ManagedSessionRecord[]; onChanged(listener: (change: ManagedSessionChange, context: Context) => void): () => void; create(options: { title: string; workspaceId: string }, context: Context): Promise; - remove(sessionId: string, context: Context): Promise; // local stop+remove, or forwarded to the managing child + remove(sessionId: string, context: Context): Promise; } ``` ### Shared contract -The directory is read; management mutates and selects. Both are presentation-safe: `ownerId` and `cwd` are stripped from summaries — **redaction**, removing fields a surface must not see. +The directory is read; management mutates and selects. Both are presentation-safe: `ownerId` and `cwd` are stripped from summaries. ```ts export interface SessionRecordSummary { @@ -591,11 +592,11 @@ export interface SessionManagementService { export const SessionManagement = defineRemoteService("session-management"); ``` -### Coordinator facet +### Server facet ```ts -// coordinator.ts -export function sessionDirectoryCoordinatorFacet(pluginContext: CodingAgentCoordinatorPluginContext) { +// server.ts +export function sessionDirectoryServerFacet(pluginContext: CodingAgentServerPluginContext) { const { managed, attachments } = pluginContext.fleet; const state = pluginContext.remoteState({ revision: 0, sessions: [] as SessionRecordSummary[] }); const events = pluginContext.remoteEvents(); @@ -611,10 +612,9 @@ export function sessionDirectoryCoordinatorFacet(pluginContext: CodingAgentCoord ); pluginContext.provide(SessionDirectory, { state, events }); - pluginContext.provide(SessionManagement, { async create(options, context) { - const client = requireClientIdentity(context); // typed value installed by transport policy + const client = requireClientIdentity(context); return toSummary(await managed.create({ title: options.title, workspaceId: client.workspaceId }, context)); }, async remove(sessionId, context) { @@ -626,31 +626,33 @@ export function sessionDirectoryCoordinatorFacet(pluginContext: CodingAgentCoord authorizeTarget(client, managed.snapshot(), sessionId); await attachments.bind(client.clientId, sessionId, context); }, - async detach(context) { await attachments.unbind(requireClientIdentity(context).clientId, context); }, + async detach(context) { + await attachments.unbind(requireClientIdentity(context).clientId, context); + }, }); } function authorizeTarget(client: ClientIdentity, records: ManagedSessionRecord[], sessionId: string) { - const record = records.find((r) => r.sessionId === sessionId); + const record = records.find((candidate) => candidate.sessionId === sessionId); if (record === undefined || record.workspaceId !== client.workspaceId) { throw new RemoteServiceError("not_authorized", `Not accessible: ${sessionId}`); } } function toSummary({ sessionId, title, workspaceId, status, updatedAt }: ManagedSessionRecord): SessionRecordSummary { - return { sessionId, title, workspaceId, status, updatedAt }; // ownerId and cwd stop here + return { sessionId, title, workspaceId, status, updatedAt }; } ``` -Every call is authorized against the client identity that transport policy installed server-locally — never against arguments. **Authorization repeats at every coordinator that owns or forwards a target:** when C0 forwards a `remove` or an attachment to C1, C1 re-checks that the target is a session it manages and that the stamped client identity is allowed. Coordinator↔coordinator links are mutually authenticated, so a child can trust its parent's identity stamping — and still refuses targets outside its own subtree. +Every call is authorized against the client identity that transport policy installed server-locally, never against identity supplied in ordinary arguments. ### TUI facet: the picker ```ts // tui.ts export async function sessionPickerTuiFacet(pluginContext: CodingAgentTuiPluginContext) { - const directory = await pluginContext.coordinator.use(SessionDirectory); - const management = await pluginContext.coordinator.use(SessionManagement); + const directory = await pluginContext.server.use(SessionDirectory); + const management = await pluginContext.server.use(SessionManagement); pluginContext.commands.register("sessions.switch", async (context) => { const entries = directory.state.value.sessions; @@ -667,65 +669,64 @@ export async function sessionPickerTuiFacet(pluginContext: CodingAgentTuiPluginC } ``` -The TUI facet never sees the tree. It consumes `SessionDirectory` exactly the way it consumes `Models`; a web facet consumes the same tokens with browser UI. There is no session facet in this plugin: sessions play no part in listing or selecting sessions. - -### Trace 1: merged directory - -```text -TUI A (at C0) coordinator.use(SessionDirectory) — terminates at C0 -C0 state = merge of local records (s0) + child-reported records (s1 via C1, s2 via C2) -C1's record for s1 changes → managed-record event C1 → C0 -→ C0 re-merges, bumps its own revision, sets state → replicated to TUI A -``` - -Each coordinator owns its merged state and its own revision counter; child revisions never propagate. TUI B, connected to C1, receives C1's directory — s1 only. A client at a child never sees parent or sibling records. +The TUI facet consumes a service provided by the one connected server. There is no session facet in this plugin because sessions do not own discovery or attachment. ### Attaching and switching `attach(sessionId)` selects the session for this presentation connection: -1. the connected coordinator authorizes the client for the target; -2. it closes the client's previous routed session state along the forwarding path: in-flight forwarded requests are cancelled, forwarded subscriptions closed, forwarded references invalidated; -3. it binds the session namespace to the new session — directly if locally managed, otherwise via the managing child (each hop authorizes); -4. the session hydrates the client with a complete fresh snapshot; `session.attachment` becomes `attached`. +1. the server authorizes the client for one of its managed sessions; +2. it closes the client's previous session-scoped requests, subscriptions, and references; +3. it binds the session namespace to the selected session worker; +4. the session worker hydrates the client with a complete fresh snapshot; `session.attachment` becomes `attached`. Session-namespace proxies are stable across switches: `session.use(Models)` resolved once keeps working against the new session. Frames belonging to closed subscriptions or requests are dropped. -### Trace 2: forwarded session call +### Routed session call ```text -TUI A (at C0, selected session s1 managed by C1): rpc.client chat.prompt -C0: authorize client for s1; map s1 → child C1; forward frame stamped with client identity -C1: verify s1 is locally managed; map s1 → session connection S1; forward +TUI A (selected session S1): rpc.client chat.prompt +server: authorize client for S1; route to session worker S1 with authenticated client identity S1: rpc.server chat.prompt — fresh local Context, validated JSON args → lane.prompt(...) -response returns S1 → C1 → C0 → TUI A; state and events flow the same path in reverse +response returns S1 → server → TUI A ``` -Cancellation follows the path: aborting the TUI request sends a cancel frame TUI → C0 → C1 → S1, aborting the session-side request controller. Trace carriers are forwarded untouched — `Context` is reconstructed only at the session, never at a coordinator hop. +Aborting the TUI request sends cancellation through the server to S1, aborting the session-side request controller. Trace carriers pass through routing; `Context` is reconstructed at the service endpoint. -### Forwarding is host infrastructure +### Routing is host infrastructure -Coordinators forward session-namespace traffic **contract-agnostically**: they parse only protocol envelopes — frame kind, request/subscription/reference IDs, service ID, target session — never business payloads or plugin schemas. Validation happens at the endpoints, exactly as on a direct connection, so a coordinator routes any plugin's session service without loading that plugin. Two consequences: +The server routes session traffic contract-agnostically. It parses protocol envelopes—frame kind, request ID, service ID, and selected session—but not plugin business payloads. Validation happens at service endpoints, so the server can route a session plugin service without loading that session facet. -- The coordinator stamps each forwarded frame with the client identity it authenticated. The session keys its per-client registries — requests, subscriptions, references — by that identity, so IDs from different presentations cannot collide and one client cannot use another's references. -- No plugin code participates in forwarding, and session services are never re-exposed as ordinary coordinator-owned proxies. A coordinator facet that wants fleet behavior implements a coordinator-owned semantic service (like the directory); it does not tunnel session contracts. +The server stamps routed calls with the client identity it authenticated. The session worker keys connection-owned requests by that identity, preventing collisions and cross-client cancellation. No server facet participates in routing or re-provides session services. -## Reverse interactions: the question plugin +## Session-owned deferred interactions: the question plugin -Some session-side work must ask a connected presentation for a decision. The existing `examples/extensions/question.ts` shows the experience: the model calls a `question` tool, the user selects an option or types an answer, and the tool returns that answer with a compact rendering. Tool execution stays in the session; the dialog stays in a presentation. The bridge is a typed **semantic interaction** — a request/response contract in JSON, never a serialized callback or component. +Some session-side work must ask users for a decision. The existing `examples/extensions/question.ts` shows the experience: the model calls a `question` tool, a user selects an option or types an answer, and the tool returns that answer with a compact rendering. -### Shared contract +A question is not a reverse RPC routed to one eligible presentation. It is shared session-owned data. Every connected TUI or web presentation must be able to show it, a presentation connecting later must discover it, and the session must continue to hold it while no users are connected. + +The session facet provides one presentation-safe service. Its tool and service implementation share the built-in durable-request binding described below; no second plugin service or plugin-owned waiter protocol is involved. + +### Shared contracts ```ts -interface QuestionRequest { - question: string; - options: Array<{ label: string; description: string | null }>; -} +const QuestionParamsSchema = Type.Object({ + question: Type.String(), + options: Type.Array( + Type.Object({ + label: Type.String(), + description: Type.Union([Type.String(), Type.Null()]), + }), + ), +}); +type QuestionRequest = Static; -type QuestionResponse = - | { outcome: "selected"; index: number } - | { outcome: "custom"; answer: string } - | { outcome: "cancelled" }; +const QuestionResponseSchema = Type.Union([ + Type.Object({ outcome: Type.Literal("selected"), index: Type.Integer({ minimum: 0 }) }), + Type.Object({ outcome: Type.Literal("custom"), answer: Type.String() }), + Type.Object({ outcome: Type.Literal("cancelled") }), +]); +type QuestionResponse = Static; interface QuestionDetails { question: string; @@ -734,42 +735,169 @@ interface QuestionDetails { wasCustom: boolean; } -const QuestionInteraction = defineInteraction("question"); +interface PendingQuestion { + id: string; + request: QuestionRequest; +} + +type DurableRequestSubmitResult = { outcome: "accepted" } | { outcome: "not_pending" }; + +interface QuestionsService { + readonly pending: RemoteState; + submitAnswer(id: string, response: QuestionResponse, context: Context): Promise; +} + +const Questions = defineRemoteService("questions", { state: ["pending"] }); +const QuestionRequests = defineDurableRequests({ + id: "questions", + request: QuestionParamsSchema, + response: QuestionResponseSchema, +}); ``` -`defineInteraction()` is a typed token like a service token. The session-side **broker** routes one request to an eligible attached presentation and returns its response: +`Questions.pending` replicates the latest pending questions to every connected presentation. `submitAnswer()` is an ordinary asynchronous remote method. The `RemoteState` adapter keeps subscription callbacks local and transports snapshots and updates internally. + +The tool-result helper remains session-local: ```ts -interface SessionInteractions { - request( - interaction: Interaction, request: Req, context: Context, - ): Promise; -} - -interface PresentationInteractions { - handle( - interaction: Interaction, handler: (request: Req, context: Context) => Promise, - ): () => void; +function questionResult(request: QuestionRequest, answer: string | null, wasCustom: boolean, text: string) { + return { + content: [{ type: "text", text }], + details: { question: request.question, options: request.options.map((o) => o.label), answer, wasCustom }, + } satisfies AgentToolResult; } ``` -The broker owns request IDs, eligible-client selection, cancellation, disconnect handling, and response validation. Handler registrations reach the session over the coordinator forwarding path when a presentation attaches, and interaction requests and responses travel that same path in reverse (S1 → C1 → C0 → TUI A and back). The contract contains no TUI concepts. +### Built-in durable requests -### Session facet: contribute the tool +A replay-safe tool waiting for external input is common enough that plugins should not implement memo keys, waiter races, restoration, state projection, or cleanup themselves. `defineDurableRequests()` declares one typed request/response channel. `bindings.durableRequests()` binds that declaration to the session Harness and returns: + +```ts +interface DurableRequests { + readonly pending: RemoteState>; + wait( + invocation: AgentHarnessToolInvocation, + request: Request, + context: Context, + ): Promise; + submit(id: string, response: Response, context: Context): Promise; +} +``` + +This is session-side infrastructure, not a fourth remote primitive. A presentation still sees only remote state and the `submitAnswer()` method deliberately exposed by the plugin. + +The helper decomposes into a small process-local join layer over a privileged Harness invocation-request store. `createDeferred()` and `awaitAbortable()` below are ordinary shared promise/cancellation utilities: + +```ts +type RegisterResult = + | { status: "pending" } + | { status: "resolved"; response: Response }; + +interface InvocationRequestStore { + readonly pending: RemoteState>; + register( + invocation: AgentHarnessToolInvocation, + request: Request, + context: Context, + ): Promise>; + submit(id: string, response: Response, context: Context): Promise; +} + +function createDurableRequests( + store: InvocationRequestStore, +) { + const waiters = new Map>(); + + return { + pending: store.pending, + + async wait(invocation: AgentHarnessToolInvocation, request: Request, context: Context): Promise { + const id = invocation.invocationId; + if (waiters.has(id)) throw new Error(`Invocation ${id} is already waiting`); + + // Install the local waiter before publishing durable pending state. If a + // subscriber answers immediately after register(), submit() can wake it. + const waiter = createDeferred(); + waiters.set(id, waiter); + try { + const result = await store.register(invocation, request, context); + if (result.status === "resolved") return result.response; + return await awaitAbortable(waiter.promise, context.abortSignal); + } finally { + if (waiters.get(id) === waiter) waiters.delete(id); + } + }, + + async submit(id: string, response: Response, context: Context): Promise { + const result = await store.submit(id, response, context); + if (result.outcome === "accepted") waiters.get(id)?.resolve(response); + return result; + }, + }; +} +``` + +The session binding itself is only composition. `DurableRequestDefinition` is the schema-bearing token returned by `defineDurableRequests()`; `HarnessInvocationRequests` is private host/Harness infrastructure: + +```ts +async function bindDurableRequests( + definition: DurableRequestDefinition, + invocationRequests: HarnessInvocationRequests, +): Promise> { + const store = await invocationRequests.open(definition); + return createDurableRequests(store); +} +``` + +`bindings.durableRequests(QuestionRequests)` uses this path to open the corresponding `InvocationRequestStore` and hydrate it before the facet is exposed. The store owns the durable part. Its two operations run on the invocation's lane mutation line: + +```text +register(invocation, request): + verify invocation is the current effect_pending, replay-safe tool call + validate request against the channel schema + read the channel's invocation-scoped request and response values + response exists → return resolved(response) + same request exists → return pending + neither exists → commit request; publish pending; return pending + +submit(invocationId, response): + locate the open invocation and verify the same effect_pending call still owns it + validate response against the channel schema + request absent → return not_pending + response exists → return not_pending + otherwise → commit response; remove pending; return accepted +``` + +The request and response use Harness-owned invocation-memo storage under keys derived from the plugin and channel IDs. Raw memo writes are not exposed to clients or plugins through `submit()`. The store fences every transition against the current tool call, and the lane mutation line makes the first response win. + +On worker replacement, the Harness already restores open operations and their effect-pending tool calls. Opening a registered channel reads its exact request and response memo keys for those calls and rebuilds `pending` before clients hydrate. `submit()` can therefore durably answer a restored question even before `execute()` is replayed and has installed a new local waiter. When the application resumes the operation, safe replay calls `wait()` with the same invocation ID: `register()` returns the stored response immediately or joins the still-pending request. + +The normal Harness outcome boundary provides cleanup. Once `execute()` returns, staging the tool result as `outcome_ready` atomically deletes all invocation memos, including the channel request and response. Cancellation and unsafe external finalization use the same invocation cleanup and remove any pending projection. No plugin-owned question log or settlement hook is required. + +### Session facet: await one durable request ```ts // session.ts -export function questionSessionFacet(pluginContext: CodingAgentSessionPluginContext) { - pluginContext.tools.add((draft) => { +export async function questionSessionFacet(bindings: SessionPluginBindings) { + const requests = await bindings.durableRequests(QuestionRequests); + bindings.provide(Questions, { + pending: requests.pending, + submitAnswer(id, response, context) { + return requests.submit(id, response, context); + }, + }); + + bindings.tools.add((draft) => { draft.set("question", { label: "Question", - description: "Ask the user a question and let them select or enter an answer.", + description: "Ask users a question and wait for an answer.", executionMode: "sequential", - parameters: QuestionParamsSchema, // typebox schema matching QuestionRequest + replay: "safe", + parameters: QuestionParamsSchema, - async execute(params: QuestionRequest, context: Context) { + async execute(_toolCallId, params, _onUpdate, _toolContext, invocation, context) { if (params.options.length === 0) return questionResult(params, null, false, "No options provided"); - const response = await pluginContext.interactions.request(QuestionInteraction, params, context); + const response = await requests.wait(invocation, params, context); if (response.outcome === "cancelled") return questionResult(params, null, false, "User cancelled the question"); if (response.outcome === "custom") return questionResult(params, response.answer, true, `User wrote: ${response.answer}`); const selected = params.options[response.index]; @@ -779,55 +907,33 @@ export function questionSessionFacet(pluginContext: CodingAgentSessionPluginCont }); }); } - -function questionResult(request: QuestionRequest, answer: string | null, wasCustom: boolean, text: string) { - return { - content: [{ type: "text", text }], - details: { question: request.question, options: request.options.map((o) => o.label), answer, wasCustom }, - } satisfies AgentToolResult; -} ``` -The session facet knows an answer is required; it knows nothing about terminal keys, dialogs, or rendering. It receives only the semantic `QuestionResponse`. +`replay: "safe"` is the durable suspension contract. While the worker is alive, `wait()` leaves `execute()` pending on a local promise. If the worker dies, that promise disappears but the Harness retains the effect-pending call and the channel retains its request or response. Safe replay re-enters `execute()` and rejoins the same durable request. With no answer, it may remain there indefinitely. -### TUI facet: dialog and renderer +While the worker is live, all clients resolve `Questions` to this same session facet. They see the same pending projection and may race to call `submitAnswer()`, but only the first durable response commit is accepted. The authority and arbitration stay in the session facet rather than in the server or any one presentation. + +### TUI and web facets: observe, answer, and render ```ts // tui.ts -export function questionTuiFacet(pluginContext: CodingAgentTuiPluginContext) { - pluginContext.interactions.handle(QuestionInteraction, async (request, context) => { - const choices = [ - ...request.options.map((o, i) => `${i + 1}. ${o.label}${o.description ? ` — ${o.description}` : ""}`), - "Type something…", - ]; - const choice = await pluginContext.ui.select(request.question, choices, { signal: context.abortSignal }); - if (choice === undefined) return { outcome: "cancelled" }; +export function questionTuiFacet(bindings: TuiPluginBindings) { + const questions = bindings.use(Questions); + bindings.own( + questions.pending.subscribe((pending, context) => { + void reconcileQuestionDialogs(bindings.ui, pending, (id, response, answerContext) => + questions.submitAnswer(id, response, answerContext), + ).catch((error) => reportQuestionUiError(error, context)); + }), + ); - const index = choices.indexOf(choice); - if (index < request.options.length) return { outcome: "selected", index }; - - const answer = await pluginContext.ui.input("Your answer", "Type something…", { signal: context.abortSignal }); - return answer?.trim() ? { outcome: "custom", answer: answer.trim() } : { outcome: "cancelled" }; - }); - - pluginContext.toolRenderers.add("question", { - renderCall(args, theme) { - const options = args.options.map((o, i) => `${i + 1}. ${o.label}`).join(", "); - const title = theme.fg("toolTitle", theme.bold("question ")) + theme.fg("muted", args.question); - return new Text(`${title}\n${theme.fg("dim", ` Options: ${options}`)}`, 0, 0); - }, - renderResult(result, theme) { - if (result.answer === null) return new Text(theme.fg("warning", "Cancelled"), 0, 0); - const prefix = result.wasCustom ? "(wrote) " : ""; - return new Text(theme.fg("success", "✓ ") + theme.fg("muted", prefix) + theme.fg("accent", result.answer), 0, 0); - }, - }); + bindings.toolRenderers.add("question", questionRenderer); } ``` -### Bundle and future facets +Each presentation independently subscribes to the same session state. If TUI and web are both connected, both open the question. When either answers, the state update makes both close it. A late or reconnected presentation hydrates the current pending state automatically. With no connected presentation, there are no remote subscribers, but the authoritative pending question remains session-owned. -`index.ts` assembles the loopback bundle: `definePlugin({ id: "@pi/question", session: questionSessionFacet, tui: questionTuiFacet })`. A future web facet registers a modal against the same token without touching session code; an IDE host can use a native quick-pick; a headless host may decline. The same mechanism covers other reverse interactions: a dangerous Bash hook requests `ConfirmInteraction` — disconnect, timeout, no eligible client, or malformed response denies the tool **fail closed**; an OAuth plugin sends an authorization URL and asks for a returned code; editor plugins exchange semantic text and selections, never component trees. Secrets need an explicitly sensitive interaction contract; secret responses must never enter replicated state, logs, events, or telemetry attributes. +A web facet uses the same `Questions` binding with a browser dialog. A headless client may ignore the service. Similar features—permissions, OAuth, or editor requests—should first decide whether they are shared deferred session state or ordinary calls; they do not get a separate interaction framework. Secrets need explicit contracts and must never enter logs or telemetry attributes. ## Lifecycle and disposal @@ -837,25 +943,24 @@ Every facet-owned resource — service registrations, contributions, state subsc Disconnect behavior, from the plugin author's perspective: -- **A presentation disconnects.** Its coordinator aborts the client's active requests (including forwarded ones, along the path), closes its subscriptions and forwarded references, and the selected session's `onClientDetach` fires. Session-owned work continues per application policy; interactions awaiting that client fail. -- **A session process disconnects or crashes.** Its coordinator fails forwarded in-flight calls with `session_unavailable` and updates the managed record's status, which propagates up the tree into every merged directory. Attached presentations see `attachment.status === "degraded"` and keep stale session-namespace state — while their coordinator namespace stays healthy, so the picker still works and the user can attach elsewhere. -- **A child coordinator disconnects from its parent.** The parent marks that child's records `unreachable` and tears down attachments forwarded through it. The child's own subtree keeps working for clients connected at or below it. -- **A process loses its coordinator.** A presentation loses both namespaces: stale state plus a disconnected `coordinator.connection`. A session loses coordinator services and all attached clients at once; unattended-session policy decides whether it exits. +- **A presentation disconnects.** Its server aborts the client's active requests, closes its session-routed resources, and the selected session's `onClientDetach` fires. Session-owned work continues per application policy. A pending question remains session-owned; its local replica becomes stale and any in-flight `submitAnswer()` invocation fails. +- **A session worker disconnects or crashes.** Its server fails routed in-flight calls with `session_unavailable` and updates the managed record's status. Attached presentations see `attachment.status === "degraded"` while the server connection stays healthy, so the picker still works and the user can attach elsewhere. +- **A process loses its server connection.** A presentation loses both namespaces. A session worker loses server services and all attached presentations at once; unattended-session policy decides whether it exits. - Reconnect and reattach always hydrate from a fresh authoritative snapshot; prior remote references are invalid unless the exposure has explicitly session-stable identity. **Never blindly replay a mutation after an uncertain disconnect** — a replayed `select()` is harmless, a replayed `prompt()` is not. Reconnect, hydrate, and reconcile, or design the operation around a stable operation ID with explicit lookup semantics. Errors cross the wire as a JSON envelope `{ code, message, data? }` with stable codes. Expected service errors use stable codes or result values; unexpected exceptions become an internal error with safe metadata — no stacks or sensitive causes by default. Cancellation, disconnect, unknown service/method, invalid arguments/results, stale references, `not_attached`, `not_authorized`, `unknown_session`, and `session_unavailable` need distinct codes. -Security rules every providing host (session and coordinator alike) must enforce: +Security rules every providing host (session and server alike) must enforce: - remote service IDs and members are allowlisted by trusted manifests or exposure descriptors; 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; -- remote methods authorize server-side even when the TypeScript client surface hides them, and **every coordinator that owns or forwards a target re-authorizes it**. +- remote methods authorize server-side even when the TypeScript client surface hides them. ## The coding agent as a plugin manifest -The coding agent is one manifest of built-in and third-party plugins; each app host loads its facets through the same kernel in deterministic order. Coordinator facets cover the directory/management services, fleet authentication and client authorization, and process spawn/stop policy and health. Session facets cover session creation/restoration and the scoped Harness facade; providers, model selection, and authentication; tools, wrappers, and permissions; prompt/steer/abort/compaction services; transcript persistence and event projection; filesystem and subprocess effects; interaction requests; unattended-session policy; and coordinator service consumption where a feature needs fleet data. TUI facets cover the chat screen, transcript rendering, editor, commands, keybindings, pickers, renderers, screens/slots/dialogs, and themes; a web host carries analogous browser facets. Shared contracts are service and interaction tokens with JSON-safe DTOs, latest-value state and semantic event types, presentation-local screen/slot tokens, stable renderer discriminators, and portable structured errors. +The coding agent is one manifest of built-in and third-party plugins; each app host loads its facets through the same kernel in deterministic order. Server facets cover the directory/management services, authentication and client authorization, and worker spawn/stop policy and health. Session facets cover session creation/restoration and the scoped Harness facade; providers, model selection, and authentication; tools, wrappers, and permissions; prompt/steer/abort/compaction services; transcript persistence and event projection; filesystem and subprocess effects; deferred interaction authority; unattended-session policy; and server service consumption where a feature needs server-wide data. TUI facets cover the chat screen, transcript rendering, editor, commands, keybindings, pickers, renderers, screens/slots/dialogs, and themes; a web host carries analogous browser facets. Shared contracts are service tokens with JSON-safe DTOs, latest-value state and semantic event types, presentation-local screen/slot tokens, stable renderer discriminators, and portable structured errors. The generic kernel knows none of these domain concepts; each app host knows only its own. @@ -863,16 +968,16 @@ The generic kernel knows none of these domain concepts; each app host knows only Before this becomes normative: -- the exact minimal kernel contract (scope shape, phase ordering, failure policy), the built-in manifest, and the concrete coordinator/session/TUI host context surfaces; +- the exact minimal kernel contract (scope shape, phase ordering, failure policy), the built-in manifest, and the concrete server/session/TUI host context surfaces; - the logical-manifest format, per-host entry-point resolution, and cross-process version pinning; - whether directory state is projected per client (workspace-scoped snapshots) or one presentation-safe value plus method authorization; - multiple selected sessions per presentation connection (a multi-pane web UI) — deferred; it changes the session-namespace API; -- authentication of presentations, sessions, and child coordinators, and protocol version negotiation; +- authentication of presentations and session workers, and protocol version negotiation; - the exact `RemoteService`/exposure-descriptor 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; -- state/event flow control and per-client buffering at forwarding coordinators; reference lifetime and garbage collection; +- 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; -- reverse-interaction routing and eligible-client policy when several presentations are attached; +- the exact `DurableRequests` API, invocation-memo key ownership and restored-channel hydration; pending-question cancellation policy, remote-state flow control, and answer authorization; - package boundaries between the generic kernel, agent plugin contracts, generic protocol machinery, and coding-agent host integration. ## Testing strategy @@ -880,9 +985,9 @@ Before this becomes normative: The handoff should include a reusable two-transport test matrix (loopback plus a real framed transport) covering: - **Composition:** hosts start from one plugin manifest, not hard-coded features; per-host entry-point resolution with no session modules reachable in a presentation bundle; provide/demand-resolve round trip; shared proxy/replica across concurrent consumers; local services unreachable remotely; duplicate/missing service failures; activation only after registration; reverse-order disposal. -- **Connections:** `Connect` performs one abortable attempt and the host owns retries; peer identity comes from the accepting coordinator's handshake; a root omits `connect`, a child uses it for its parent, and `coordinator.local` runs the same host boundaries without sockets. +- **Connections:** `Connect` performs one abortable attempt and the host owns retries; peer identity comes from the accepting server's handshake; session and presentation hosts connect to that server, and `server.local` runs the same host boundaries without sockets. - **RPC and context:** JSON call and `void` result; invalid argument/result and unknown service/member rejection; client span → `rpc.client` → `rpc.server` → service span; per-call cancellation isolation; disconnect aborts active calls; no callback, context, signal, telemetry object, or secret in wire JSON. - **State and events:** snapshot plus concurrent update with no gap; update ordering and detached values; reconnect replaces stale state; subscribe/unsubscribe and disconnect cleanup; deliveries carry reconstructed source contexts; immediate snapshot callbacks run under the hydration context. - **Registries:** ordered provider contributions rebuild deterministically; tool wrappers compose once and rebuild correctly after removal. -- **Forwarding:** a parent's directory merges local and child records and updates on child record events; a client at a child sees no parent or sibling records; attach authorizes at the connected coordinator and again at the managing child, rejecting cross-workspace targets; attach/switch closes previous forwarded requests/subscriptions/references and hydrates a complete fresh snapshot; a forwarded call across two coordinators reconstructs `Context` only at the session, with cancel frames and trace carriers traversing the path; per-client keying prevents ID collisions and cross-client reference use; coordinators forward services whose contracts they do not load; session crash leaves the coordinator namespace healthy, retains stale session state, and updates directory status; child coordinator disconnect marks records unreachable and tears down forwarded attachments while the child's subtree keeps serving; presentation disconnect cleanup reaches the session; summaries contain no `ownerId` or `cwd`. -- **Boundaries:** scoped agent and fleet capabilities cannot reach host ownership or whole-registry mutation; raw Harness/Session/SessionRepo capabilities unreachable from any client connection; job `wait` cancellation distinct from `job.cancel()`; stale/closed reference rejection; reverse-interaction success, timeout, disconnect, and fail-closed permission behavior; invocation cancellation writes no durable cancellation state. +- **Routing:** attach authorizes at the server and rejects cross-workspace targets; attach/switch closes previous routed requests and hydrates the selected session; a routed call reconstructs `Context` at the session worker, with cancellation and trace carriers traversing the server; per-client keying prevents ID collisions; the server routes services whose contracts it does not load; session-worker failure leaves server services healthy and updates directory status; presentation disconnect cleanup reaches the session; summaries contain no `ownerId` or `cwd`. +- **Boundaries:** scoped agent and fleet capabilities cannot reach host ownership or whole-registry mutation; raw Harness/Session/SessionRepo capabilities unreachable from any client connection; job `wait` cancellation distinct from `job.cancel()`; stale/closed reference rejection; one pending question appears through remote state in simultaneous TUI and web clients, survives having no connected presentation, hydrates from its invocation memo after worker restart before tool replay, accepts an answer with or without a live waiter, returns that answer when the Harness safely replays the tool, rejects later answers, cleans up with durable tool-result staging, and closes in every presentation; invocation cancellation writes no durable cancellation state.