From 198c04b8f407195d1972917ff02397796d7914a6 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 18 Aug 2026 01:04:56 +0200 Subject: [PATCH] docs(agent): define coordinator plugin topology --- packages/agent/docs/plugins.md | 992 ++++++++++----------------------- 1 file changed, 298 insertions(+), 694 deletions(-) diff --git a/packages/agent/docs/plugins.md b/packages/agent/docs/plugins.md index 034a12ef2..4e6bdb23a 100644 --- a/packages/agent/docs/plugins.md +++ b/packages/agent/docs/plugins.md @@ -2,64 +2,47 @@ > **Status:** Tentative design input, not a normative contract or implementation handoff. The examples use illustrative APIs that do not exist yet. 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`, telemetry, and the generic RPC design at a conceptual level. Read `rpc.md` for wire frames, remote references, subscriptions, and trace carriers, and `telemetry.md` for context propagation and cancellation semantics. +This document assumes you already understand `AgentHarness`, `AgentLane`, `Session`, `SessionTree`, `SessionRepo`, invocation `Context`, and telemetry. Read `rpc.md` for wire frames, remote references, subscriptions, and trace carriers, and `telemetry.md` for context propagation and cancellation semantics. ## Bird's-eye view -The new coding agent is not a monolithic application with an extension API bolted on. It is assembled from an ordered plugin manifest. The architecture has three distinct layers: +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, browsers, RPC services, or coding-agent policy. -2. An **application host** owns one concrete runtime and constructs the plugin context for that runtime. The session host exposes session authority and session contribution registries. The TUI host exposes terminal UI facilities and TUI contribution registries. A future web host exposes browser facilities and web contribution registries. A **coordinator** (server) host manages session processes and exposes fleet authority: session records (`SessionRepo`) and process management. -3. A **plugin package** implements one feature as one or more host-specific **facets**. Each facet receives only the context for its host. +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. + +Coordinators form a strict **management tree**. Every other process connects to exactly one coordinator: ```text - ordered coding-agent manifest - ┌──────────────┼──────────────┐ - │ │ │ - session facets TUI facets future web facets - │ │ │ - ┌─────────▼────────┐ ┌───▼──────────┐ ┌─▼────────────┐ - │ session app host │ │ TUI app host │ │ web app host │ - │ + plugin kernel │ │ + kernel │ │ + kernel │ - ├─ host context ───┤ ├─ context ────┤ ├─ context ────┤ - │ scoped Harness │ │ RPC services │ │ RPC services │ - │ Session/Tree │ │ TUI/dialogs │ │ DOM/routes │ - │ tools/providers │ │ renderers │ │ web views │ - └─────────┬────────┘ └──────┬───────┘ └──────┬───────┘ - │ │ │ - └──────── semantic RPC/state/events/interactions ────────┘ + 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 ``` -The plugin kernel is reusable machinery. It does not define a universal `PluginContext`. Each app host deliberately defines the authority and extension points its facets receive. +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 deployment adds one more host of the same shape upstream of the session process. The coordinator is not the session process; it manages session processes: +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. -```text -TUI / web app hosts ──▶ session app host ──▶ coordinator app host - RPC clients of the service host for service host for - session process presentations; session processes; - RPC client of the owns SessionRepo and - coordinator process management -``` - -Everything a user recognizes as "the coding agent" — providers, model selection, authentication, chat, core tools, permissions, transcript rendering, and the model picker — comes from these facets. Built-ins and packaged third-party extensions use the same host APIs. There is no privileged built-in path hidden in a kernel. - -One terminology rule: **host** and **client** are roles per connection, not fixed process kinds. Every presentation host process (TUI, web, IDE) is an RPC client of the session host; the session process is simultaneously the service host for its presentations and an RPC client of the coordinator. "Client" below always names the connection role — the peer of a disconnect, a subscription, a replicated snapshot. It never names a kind of plugin; plugins have host facets. +**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. ## Why this shape -Concretely, for the examples in this document: - -- **Authority stays where it belongs.** Provider credentials and tool execution exist only in the session process; `SessionRepo` and process control only in the coordinator. 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 with host-native capabilities. +- **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. +- **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. -- **Topology composes.** Because host and client are per-connection roles, the session process serves downstream presentations while consuming upstream coordinator services with the same machinery; further tiers need no new concepts. +- **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 full TUI → session → coordinator chain against a real transport — independently. +- **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. ## One feature, several host facets -A coding-agent plugin is a feature bundle. It may provide a facet for any host: +A coding-agent plugin is a feature bundle that may provide a facet for any host: ```ts interface CodingAgentPlugin { @@ -73,9 +56,7 @@ interface CodingAgentPlugin { type PluginFacet = (context: Context) => void | Promise; ``` -A facet is a setup function over its host's context type; `PluginFacet` is the only plugin-facing shape the generic kernel needs. Names such as `coordinator`, `session`, `tui`, and `web`, and the context types behind them, belong to the coding-agent application. Examples below use an illustrative `definePlugin()` helper that returns a `CodingAgentPlugin` — the loaded, in-process shape. Across processes, only the logical manifest (plugin identity, order, version) is shared; each host loads its own facet module, as startup shows. - -Facets are optional. A `models.json` plugin may have only a session facet. A terminal theme may have only a TUI facet. A fleet feature such as the session directory adds a coordinator facet. Chat normally has a session facet that owns prompt authority, a TUI facet that contributes the terminal transcript and editor, and eventually a web facet that contributes browser-native presentation. "Two halves" is useful shorthand for session plus one presentation, but it is not the architecture's limit. +`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. A package keeps shared wire contracts separate from host dependencies: @@ -88,43 +69,39 @@ question-plugin/ index.ts loopback bundle; production hosts resolve per-host entry points ``` -The browser build never imports `session.ts`; the session process never imports TUI or DOM code. Small compatible facets may share a module, but the host-specific dependency boundary must remain valid. +The browser build never imports `session.ts`; the session process never imports TUI or DOM code. -The question plugin is this document's end-to-end example. Its complete trace: +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 terminal dialog (TUI host context) -→ TUI facet returns a JSON response (shared contract) +→ TUI facet presents a dialog, returns JSON (TUI host context) → session facet returns the durable tool result -→ TUI facet renders that result with its contributed renderer +→ TUI facet renders it with its contributed renderer ``` -The models service in the next sections illustrates the service and replicated-state machinery this flow does not need; the question sections then make the full round trip concrete. +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. ## What app startup does -Each runtime instantiates the same generic lifecycle kernel through a different app host. What every host shares is the **logical manifest** — plugin identity, order, and version, JSON-safe — never a JavaScript object. Each host resolves facet **modules** for those IDs from per-host entry points, so a host never imports code it cannot run: +Every host shares the **logical manifest** — plugin identity, order, and version, JSON-safe — never a JavaScript object. Each host resolves facet modules for those IDs from per-host entry points (for example, package export conditions `./coordinator`, `./session`, `./tui`, `./web`); a missing entry point means no facet for that host. Resolution, not convention, enforces the dependency boundary. ```ts -// logical manifest: shared identity and order (JSON-safe, same in every process) const manifest: PluginManifestEntry[] = [ { id: "@pi/session-core" }, { id: "@pi/providers-builtin" }, - { id: "@pi/chat" }, { id: "@pi/question" }, - { id: "@pi/session-directory" }, ]; -await coordinatorAppHost.start(manifest); // resolves each plugin's ./coordinator entry point -await sessionAppHost.start(manifest); // resolves each plugin's ./session entry point -await tuiAppHost.start(manifest); // resolves each plugin's ./tui entry point +await coordinatorAppHost.start(manifest); // every coordinator process; resolves ./coordinator +await sessionAppHost.start(manifest); // every session process; resolves ./session +await tuiAppHost.start(manifest); // every TUI process; resolves ./tui ``` -A plugin package declares per-host entry points (for example, package export conditions `./coordinator`, `./session`, `./tui`, `./web`); a missing entry point means no facet for that host. The web bundle therefore contains only `contract.ts` and `web.ts` — "browser builds never import `session.ts`" is enforced by resolution, not convention. A loopback single-process app may instead load one `CodingAgentPlugin` bundle with several facets; each facet still receives only its own host context. The coordinator host runs once and starts session processes on demand; each session process runs its own session host. +A loopback single-process app may instead load one `CodingAgentPlugin` bundle with several facets; each facet still receives only its own host context. -For each selected facet, an app host asks the kernel to create a lifecycle scope, builds its host-specific context around that scope, and invokes the facet. The scope is the kernel's entire per-facet contract: +For each selected facet, the host asks the kernel to create a lifecycle scope, builds its host-specific context around that scope, and invokes the facet. The scope is the kernel's entire per-facet contract: ```ts interface PluginLifecycleScope { @@ -136,57 +113,37 @@ interface PluginLifecycleScope { Host contexts extend this scope. Host infrastructure calls `own()` whenever a facet registers a service, contribution, or subscription, so removal never depends on the facet undoing anything by hand. Nothing else in this document — services, tools, dialogs, interactions — is kernel API. -Once all facets have registered their services and contributions, the kernel runs activation callbacks. If setup or activation fails, the kernel disposes already-created scopes in reverse order. Normal shutdown does the same. +Startup runs in deterministic manifest order and two phases: facets register services, contributions, and callbacks, and all registrations become visible; then `onActivate` callbacks start background work and the host exposes its authoritative state snapshot to its connections. This prevents one facet from starting effects while another is still constructing a dependency. Duplicate service IDs reject during registration; missing services and cycles are trusted application-assembly errors — no dependency-injection framework. If setup or activation fails, the kernel disposes already-created scopes in reverse order; normal shutdown does the same. -The result is a clean ownership chain: +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. -```text -process/runtime -└─ app host - └─ generic plugin kernel - └─ plugin facet scope - ├─ service registrations - ├─ contributions and subscriptions - ├─ in-flight work and child controllers - └─ activation/disposal callbacks -``` +One process owns one session, and session authority never migrates. Each process has exactly one multiplexed connection to its coordinator; plugins never open private sockets. Plugin authors never implement request IDs, sockets, trace carriers, cancellation frames, remote-reference registries, 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 into presentation hosts. The coordinator owns no session content: it owns session records and process management. Session-to-presentation traffic uses one multiplexed connection, and session-to-coordinator traffic uses one multiplexed upstream connection; plugins do not open private sockets. A loopback app preserves the same semantic boundary and serialization rules. Plugin authors do not 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 conflict resolution, automatic mutation replay after reconnect, a universal cross-process action registry, a universal remote `AgentHarness` or serialized UI tree, and blind re-exposure of upstream proxies, references, or subscription frames onto downstream connections. +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. ## Services connect host facets -Facets communicate across hosts through **services**. A service token is a typed identity plus trusted exposure metadata for the generic RPC layer: +Facets communicate across processes through **services**. A service token is a typed identity plus trusted exposure metadata for the generic RPC layer: ```ts function defineRemoteService(id: string, exposure?: ServiceExposure): RemoteService; ``` -The exact exposure-descriptor API belongs to `rpc.md`; these examples use `state`, `events`, and `results` only to illustrate the required capabilities. +The exact exposure-descriptor API belongs to `rpc.md`; examples use `state`, `events`, and `results` only to illustrate the required capabilities. -The next sections use the models service — the authority behind the model picker and thinking-level control — because it exercises methods, replicated state, and multiple consumers. The question plugin returns as the end-to-end trace in [Reverse interactions](#reverse-interactions-the-question-plugin-end-to-end). +The models service — the authority behind the model picker and thinking-level control — exercises methods, replicated state, and multiple consumers. ### Shared contract ```ts -export type ThinkingLevel = "off" | "low" | "high"; - export interface ModelRef { provider: string; modelId: string; } -export interface ModelSpec extends ModelRef { - name: string; - reasoning: boolean; -} - export interface ModelsState { - catalog: { revision: number; availableModels: ModelSpec[] }; - configuration: { model: ModelRef | null; thinkingLevel: ThinkingLevel }; + catalog: { revision: number; availableModels: Array }; + configuration: { model: ModelRef | null; thinkingLevel: "off" | "low" | "high" }; refresh: | { status: "idle" | "refreshing" | "done" } | { status: "warning"; errors: Record }; @@ -202,7 +159,7 @@ export interface ModelsService { export const Models = defineRemoteService("models", { state: ["state"] }); ``` -Everything in the contract is strict JSON: arguments, results, replicated state, and events. That is why the contract uses `null` rather than `undefined` inside state. `Context` is control-plane data in a declared position; it is stripped by the proxy and never serialized. +Everything in a contract is strict JSON: arguments, results, replicated state, events. That is why state uses `null`, never `undefined`. `Context` is control-plane data in a declared position; the proxy strips it and it is never serialized. ### Session facet @@ -212,53 +169,25 @@ export const providersBuiltin = definePlugin({ session(pluginContext: CodingAgentSessionPluginContext) { const providers = new ProviderRegistry(); // process-local, non-JSON - const state = pluginContext.remoteState({ - catalog: { revision: 0, availableModels: [] }, - configuration: { model: null, thinkingLevel: "high" }, - refresh: { status: "idle" }, - }); + const state = pluginContext.remoteState(initialModelsState()); pluginContext.provide(Models, { state, async select(model, context) { - context.abortSignal?.throwIfAborted(); - const spec = state.value.catalog.availableModels.find( - (m) => m.provider === model.provider && m.modelId === model.modelId, - ); + const spec = findSpec(state.value.catalog, model); if (spec === undefined) throw new Error(`Unknown model: ${model.provider}/${model.modelId}`); - state.set( - { - ...state.value, - configuration: { - model, - thinkingLevel: spec.reasoning ? state.value.configuration.thinkingLevel : "off", - }, - }, - context, - ); - }, - - async cycleThinking(context) { - const configuration = cycleThinkingLevel(state.value.configuration); // off → low → high → off - state.set({ ...state.value, configuration }, context); + const thinkingLevel = spec.reasoning ? state.value.configuration.thinkingLevel : "off"; + state.set({ ...state.value, configuration: { model, thinkingLevel } }, context); }, async refresh(context) { state.set({ ...state.value, refresh: { status: "refreshing" } }, context); const errors = await providers.refresh(context.abortSignal); - state.set( - { - ...state.value, - catalog: providers.snapshot(), - refresh: - errors.size === 0 - ? { status: "done" } - : { status: "warning", errors: Object.fromEntries(errors) }, - }, - context, - ); + state.set({ ...state.value, catalog: providers.snapshot(), refresh: toRefreshStatus(errors) }, context); }, + + // cycleThinking is analogous: read configuration, state.set(next, context) }); pluginContext.onActivate(() => providers.rebuild()); @@ -273,58 +202,31 @@ export const modelSelection = definePlugin({ id: "@pi/model-selection", async tui(pluginContext: CodingAgentTuiPluginContext) { - const models = await pluginContext.use(Models); - - pluginContext.commands.register("models.select", (commandContext, model: ModelRef) => - models.select(model, commandContext), - ); - pluginContext.commands.register("models.refresh", (commandContext) => models.refresh(commandContext)); - + const models = await pluginContext.session.use(Models); + pluginContext.commands.register("models.select", (ctx, model: ModelRef) => models.select(model, ctx)); models.state.subscribe((next) => renderModelSelector(next)); }, }); ``` -The TUI facet has no provider credentials, no registry, and no refresh logic. It calls a typed proxy and renders replicated state. A future web facet would do exactly the same through the web host context. +The TUI facet has no credentials, registry, or refresh logic: it calls a **typed proxy** — a generated object with the contract's method signatures whose calls become RPC — and renders replicated state. A web facet would do the same through the web host context. ### Service semantics -A service has **one owner and many consumers**. `providersBuiltin` provides `Models`; the model picker and the thinking-level control both consume it: +A service has **one owner and many consumers**: `providersBuiltin` provides `Models`; the model picker and the thinking-level control (one command bound to `ctrl+t`) both consume it. -```ts -export const thinkingControl = definePlugin({ - id: "@pi/thinking-control", - async tui(pluginContext: CodingAgentTuiPluginContext) { - const models = await pluginContext.use(Models); - pluginContext.commands.register("thinking.cycle", (context) => models.cycleThinking(context)); - pluginContext.keybindings.bind("ctrl+t", "thinking.cycle"); - }, -}); -``` - -`use()` has two modes, and the difference matters: +`use()` has two modes: - **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. +- **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 hosts consume session services remotely. The session host consumes coordinator services remotely through `upstream.use()` — the same semantics over its one upstream connection (see the [session directory](#chained-hosts-the-session-directory-end-to-end)). A process may provide services on one connection and consume them on another. - -`provide()`, `use()`, and `remoteState()` are host infrastructure layered over the kernel scope, not kernel API. The complete host contexts appear in the next section. - -Registration and activation are separate kernel phases, in deterministic manifest order: - -1. facets run and register services, contributions, and lifecycle callbacks; -2. all registrations become visible; -3. `onActivate` callbacks start background work (the provider rebuild above); -4. the host exposes its authoritative state snapshot to its connections. - -This prevents one facet from starting effects while another is still constructing a dependency. Duplicate service IDs reject during registration. Missing services and dependency cycles are trusted application-assembly errors; this design does not need a dependency-injection framework. +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. ## What each host context grants -This is the most important boundary in the design, so it is worth stating bluntly. +This is the most important boundary in the design. -**Session facets run beside the real thing.** They execute in the process that owns the concrete `AgentHarness`, `AgentLane`, `Session`, and `SessionTree`, and they receive direct, process-local, scoped capabilities backed by those instances — not RPC proxies. Calls preserve the real method signatures, `Context` propagation, `Result` types, and object identity. They do not serialize arguments, create remote references, or start `rpc.client`/`rpc.server` spans. A session facet never RPCs back into its own process. +**Session facets run beside the real thing.** They execute in the process that owns the concrete `AgentHarness`, `AgentLane`, `Session`, and `SessionTree`, and receive direct, process-local, scoped capabilities backed by those instances — not RPC proxies. Calls preserve real method signatures, `Context` propagation, `Result` types, and object identity. A session facet never RPCs back into its own process. ```ts interface AgentPluginScope { @@ -337,8 +239,7 @@ interface AgentPluginScope { } interface CodingAgentSessionPluginContext extends PluginLifecycleScope { - // scoped local session authority - readonly agent: AgentPluginScope; + readonly agent: AgentPluginScope; // scoped local session authority // service/state infrastructure (session-host owned) provide(service: RemoteService | LocalService, implementation: T): void; @@ -346,23 +247,39 @@ interface CodingAgentSessionPluginContext extends PluginLifecycleScope { remoteState(initial: T): MutableRemoteState; remoteEvents(): MutableRemoteEvents; - // upstream access to the coordinator (session host is the client here) - readonly upstream: SessionUpstream; + readonly coordinator: CoordinatorServices; // remote access to the managing coordinator // contribution registries (session-host owned) readonly providers: ProviderContributionRegistry; readonly tools: ToolContributionRegistry; - // reverse interactions and connection lifecycle + // reverse interactions and attached-client lifecycle readonly interactions: SessionInteractions; - onClientConnect(callback: (clientId: string) => void): void; - onClientDisconnect(callback: (clientId: string) => void): void; + onClientAttach(callback: (clientId: string) => void): void; + onClientDetach(callback: (clientId: string) => void): void; +} +``` + +"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. + +```ts +interface CoordinatorServices { + use(service: RemoteService): Promise; // terminates at the connected coordinator + readonly connection: RemoteState; +} + +type AttachmentState = { status: "detached" } | { status: "attaching" | "attached" | "degraded"; sessionId: string }; + +interface SessionServices { + use(service: RemoteService): Promise; // forwarded to the one selected session + readonly attachment: RemoteState; } interface CodingAgentTuiPluginContext extends PluginLifecycleScope { - // remote semantic access (presentation-host infrastructure) - use(service: RemoteService): Promise; - readonly connection: RemoteState; + readonly coordinator: CoordinatorServices; // two explicit remote namespaces + readonly session: SessionServices; readonly interactions: PresentationInteractions; // terminal-specific facilities and contribution registries @@ -374,47 +291,11 @@ interface CodingAgentTuiPluginContext extends PluginLifecycleScope { } ``` -A future `CodingAgentWebPluginContext` carries the same remote semantic access plus browser-specific registries — routes, views, DOM dialogs. The coordinator host context is defined with the [session directory example](#chained-hosts-the-session-directory-end-to-end). Contexts resemble each other by convention; no kernel-owned base type forces the shape. +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"`). -"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 the `sessionTree` may be the actual local derived object. The host keeps the unrestricted concrete instances and reserves: +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. -- `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 that cannot be disposed with the plugin; -- 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 application manifest may explicitly grant a plugin broader local capabilities when its responsibility requires them, but built-ins receive no implicit bypass and broad access is not the default contract. - -**Presentation facets hold none of this.** A TUI or web facet never receives the raw `AgentHarness`, `Session`, `SessionTree`, tool registry, hooks, or credentials — not even as proxies. It receives only what a session facet deliberately exposes: semantic service proxies, replicated state, and semantic events/interactions. - -```text -concrete Harness / Session / SessionTree -→ direct local scoped capability in a session facet -→ plugin-defined semantic service -→ generic RPC -→ typed proxy in a presentation facet -``` - -Concretely, a chat plugin exposes a narrow shared contract: - -```ts -interface PromptResponse { - accepted: boolean; - operationId: string | null; - error: { code: string; message: string } | null; -} - -interface ChatService { - prompt(request: { message: AgentMessage[] }, context: Context): Promise; - requestAbort(operationId: string, context: Context): Promise; -} - -const Chat = defineRemoteService("chat"); -``` - -Its session facet implements that contract with direct local lane capabilities: +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: ```ts session(pluginContext: CodingAgentSessionPluginContext) { @@ -430,28 +311,14 @@ session(pluginContext: CodingAgentSessionPluginContext) { } ``` -Its TUI facet consumes `Chat` exactly as the model picker consumes `Models`: a typed proxy, a command registration, nothing else. A review plugin similarly exposes review operations. Neither reveals the Harness object that implements them, and there is no universal remote Harness available to arbitrary plugins. +Its TUI facet consumes `Chat` through `session.use()` exactly as the model picker consumes `Models`. Neither reveals the Harness object behind them; there is no universal remote Harness for arbitrary plugins. `rpc.md` may still define generic Harness proxies for other trusted integrations (an IDE bridge, an orchestrator) — deliberate, separate exposures, not the plugin boundary. -`rpc.md` may still define generic Harness proxies for other trusted integrations (an IDE bridge, an orchestrator). Those are deliberate, separate exposures — not the coding-agent plugin boundary. +## Local services and narrow remote facades -## Local services and narrow remote façades - -Not every dependency should be remotely reachable. A **local service** is a token confined to its providing process: +Not every dependency should be remotely reachable. A **local service** is a token confined to its providing process. It may hold functions, native objects, credentials, or filesystem handles; remote `use()` cannot resolve it, and local services are never discoverable remotely. The pattern for sensitive state is a local full service plus a narrow remote facade: ```ts -const Providers = defineLocalService("providers"); -``` - -A local service may hold functions, native objects, credentials, filesystem handles, or other non-JSON state. Presentation-host `use()` cannot resolve it because it has no remote exposure, and local services are never discoverable remotely. - -The pattern for sensitive state is a local full service plus a narrow remote façade. Credentials are the canonical example: - -```ts -interface CredentialStore { - get(provider: string): Promise; - set(provider: string, credential: string): Promise; -} -const Credentials = defineLocalService("credentials"); +const Credentials = defineLocalService("credentials"); // get/set provider secrets interface AccountsService { readonly state: RemoteState<{ providers: Array<{ provider: string; configured: boolean }> }>; @@ -460,13 +327,11 @@ interface AccountsService { const Accounts = defineRemoteService("accounts", { state: ["state"] }); ``` -The auth plugin's session facet uses `Credentials` directly. Presentation hosts see provider IDs and `configured` booleans — never secrets, filesystem paths, or credential-store methods. - -A settings service shows both `use()` modes on one token: a provider plugin's session facet reads `defaultModel` at activation through synchronous local `use()` under `BACKGROUND_CONTEXT`; a theme plugin's TUI facet writes through the async proxy with its command context. If some settings must not be remotely writable, split them: a local full service plus a narrower remote preferences façade. Do not rely on presentation-side convention. +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` -`ModelsService.state` above is a `RemoteState`. This is **authoritative latest-value replication**, nothing more: it is not event history, durable storage, a CRDT, or a multi-writer synchronization mechanism. +`ModelsService.state` is a `RemoteState`: **authoritative latest-value replication** — not event history, durable storage, a CRDT, or a multi-writer mechanism. ```ts interface RemoteState { @@ -482,17 +347,17 @@ interface MutableRemoteState extends RemoteState { Required behavior: 1. The providing host owns the one authoritative value; there are no remote writes. -2. The initial connection snapshot includes all exposed state values atomically, and updates emitted while the snapshot installs are buffered — so a client observes snapshot-then-updates with no gap. (The wire mechanics live in `rpc.md`.) -3. After readiness, client `.value` is synchronously readable and `subscribe()` immediately reports the current value, then future updates. The immediate callback runs under a defined **hydration context** — fresh, parented to the subscription/hydration operation — because the write that produced the value may be long settled. -4. Values are detached with structured JSON semantics, so one listener cannot mutate another's state. -5. Reconnect replaces client state from a fresh authoritative snapshot; disconnect retains the last value as stale display data alongside a disconnected connection state. -6. `set(value, context)` carries source trace metadata, and each subsequent delivery invokes listeners with a reconstructed delivery `Context` carrying it — the same reconstruction event delivery uses. Background updates use an intentional background or lifecycle context, never a retained old caller context. +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. -The running example combines catalogue, selected configuration, and refresh status into one `ModelsState` for brevity. Production may split them into coarse independent cells so a refresh replaces the catalogue without retransmitting unrelated configuration, while the picker rerenders from whichever cell changed. Prefer several coarse cells over a universal patch language. Large, infrequently changing catalogues may use compressed replacement. High-frequency data such as transcript or token streaming should use semantic deltas followed by a final authoritative replacement, not repeated whole-transcript snapshots. Production transport should add epoch/revision metadata, gap recovery, unchanged-value suppression, and demand-driven per-service subscription; those are protocol concerns, not plugin-author concerns. +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. ## Contribution registries: many contributors, one result -Services fit one owner and many consumers. Providers and tools invert that: **many plugins contribute to one host-owned result**. Handing every plugin a mutable global registry would make composition order-dependent and removal impossible. Instead, a contribution registry replays ordered contributions over a fresh draft: +Services fit one owner, many consumers. Providers and tools invert that: **many plugins contribute to one host-owned result**. A mutable global registry would make composition order-dependent and removal impossible. A contribution registry instead replays ordered contributions over a fresh draft: ```text fresh ProviderDraft @@ -508,9 +373,6 @@ Removing a plugin removes its contribution and rebuilds; nothing runs an inverse ```ts sessionContext.tools.add((draft) => { draft.set("review_add", reviewAddTool); -}); - -sessionContext.tools.add((draft) => { draft.wrap("bash", (next) => async (invocation) => { await authorize(invocation); return next(invocation); @@ -518,19 +380,17 @@ sessionContext.tools.add((draft) => { }); ``` -Ordered wrappers compose deterministically — `telemetry(permission(sandbox(coreBash)))` — and if the permission plugin disappears, rebuilding yields `telemetry(sandbox(coreBash))`. No wrapper is double-applied, and no plugin has to know how to unwrap another. - -Only the host finalizes the draft and applies the complete registry to the Harness. Plugins never call whole-registry setters such as `AgentHarness.setTools()`. Contributions configure rebuilt behavior; hooks intercept live operations — they are separate mechanisms. A generic replay-fold utility may be shared, but provider/tool draft semantics, duplicate policy, validation, and rebuild triggers remain coding-agent policy. +Ordered wrappers compose deterministically — `telemetry(permission(sandbox(coreBash)))` — and if the permission plugin disappears, rebuilding yields `telemetry(sandbox(coreBash))`. Only the host finalizes the draft and applies the complete registry to the Harness; plugins never call `setTools()`. Contributions configure rebuilt behavior; hooks intercept live operations — separate mechanisms. ## Context, cancellation, and telemetry for plugin authors -`rpc.md` and `telemetry.md` own the mechanisms: trace carriers, request frames, cancel messages, server context reconstruction. What a plugin author needs to know: +`rpc.md` and `telemetry.md` own the mechanisms. What a plugin author needs to know: - Every remote method receives a `Context` in its declared position. The proxy strips it from the JSON arguments and uses it to parent `rpc.client`, inject the trace carrier, and map `context.abortSignal` to request cancellation. -- The server never deserializes the client's context. It constructs a fresh one: a request-local abort signal, a telemetry parent extracted from the trace carrier, and server-created typed values such as the authenticated client ID. Clients cannot smuggle context values across; a presence service reads `context.value(CLIENT_ID)` that transport policy installed server-side. +- The server never deserializes the client's context. It constructs a fresh one: a request-local abort signal, a telemetry parent extracted from the trace carrier, and server-created typed values such as the authenticated client identity. Clients cannot smuggle context values across. - No receiver-level defaults: shared service objects never retain a caller's context. -The model refresh shows the whole author-visible surface. Client side: +The model refresh shows the whole author-visible surface: ```ts const controller = new AbortController(); @@ -540,18 +400,7 @@ await uiTelemetry.startSpan({ name: "ui.models.refresh" }, async (span) => { }); ``` -Session side, the implementation may open its own span and derive the context for lower work: - -```ts -async refresh(context) { - return context.telemetryContext.startSpan({ name: "plugin.models.refresh" }, async (span) => { - const refreshContext = withTelemetryContext(span, context); - // ... provider refresh under refreshContext ... - }); -} -``` - -The resulting distributed trace: +The implementation may open its own span for lower work, producing the distributed trace: ```text ui.models.refresh @@ -560,7 +409,7 @@ ui.models.refresh └─ plugin.models.refresh ``` -Calling `controller.abort()` cancels only that one request: the server's reconstructed `context.abortSignal` aborts, and no other caller is affected. +`controller.abort()` cancels only that one request: the server's reconstructed `context.abortSignal` aborts, and no other caller is affected. Three cancellation domains must never blur: @@ -568,11 +417,11 @@ Three cancellation domains must never blur: 2. **Service-owned cancellation** is an explicit method such as `job.cancel()` that stops a service-owned task. 3. **Durable Harness cancellation** — `requestAbort()`/`abort()` — writes durable `cancel_requested` and drives durable settlement. -A transport disconnect performs only the first for active requests and closes that client's subscriptions. It must not silently cancel service-owned work or write durable cancellation. Work intended to outlive its initiating request must deliberately detach into a service-owned task with its own controller and telemetry root, which brings us to jobs and events. +A transport disconnect performs only the first for active requests and closes that client's subscriptions. It must not silently cancel service-owned work or write durable cancellation. Work intended to outlive its initiating request must deliberately detach into a service-owned task with its own controller and telemetry root. ## Events -A remotely exposed event source is a projection of host-local events, not a remotely invoked callback. A Git plugin exposes repository state plus a change stream: +A remotely exposed event source is a projection of host-local events, not a remotely invoked callback. A Git plugin: ```ts interface GitService { @@ -583,84 +432,85 @@ interface GitService { const Git = defineRemoteService("git", { state: ["status"], events: ["events"] }); ``` -Server-side, `events` is an ordinary host-local event source; the exposure adapter subscribes once per remote subscription and forwards frames. `on(type, listener)` filters by discriminator; `subscribe(listener)` observes every event. Client-side, `events` is a local façade whose listeners run in the client process — callback functions never cross the wire: - -```ts -git.status.subscribe((status) => renderGitStatus(status)); -git.events.on("status_changed", (_event, context) => { - void context.telemetryContext.startSpan({ name: "ui.git.status_changed" }, () => refreshView()); -}); -``` - -Each event carries source trace metadata from the providing host's context that caused it, 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 instead re-hydrate from a fresh state 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`). Critical resumable streams need stable event IDs and a replay policy; passive UI invalidation can re-hydrate from a fresh snapshot after reconnect. ## Service-owned jobs -Long-running work should return a capability rather than one method that blocks indefinitely. A background indexing service: +Long-running work should return a capability rather than one method that blocks indefinitely: ```ts interface IndexJob { readonly progress: RemoteState; - wait(context: Context): Promise; - cancel(context: Context): Promise; + wait(context: Context): Promise; // aborting this context cancels only this wait + cancel(context: Context): Promise; // cancels the job itself, for everyone } - -interface IndexService { - start(root: string, context: Context): Promise; -} -const Index = defineRemoteService("index", { - results: { start: remote(IndexJobExposure) }, -}); ``` -Session-side, `start()` validates the root, creates its own `AbortController` and a detached telemetry root for the task, and returns the job. The returned job crosses the wire as a remote object reference (`rpc.md`). +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 job makes the cancellation domains concrete: aborting one client's `wait(context)` cancels only that client's wait; calling `job.cancel()` cancels the job itself, for everyone. This mirrors the invocation-versus-service-owned distinction above. The exposure registry must release completed job references under an explicit lifetime policy. +## The coordinator: directory, management, and forwarding -## Chained hosts: the session directory end-to-end +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. -Host and client are connection roles, and one process routinely plays both. The session process is the service host for its attached presentations and an RPC client of the coordinator — the separate server process that manages session processes and owns the `SessionRepo`. The session-directory plugin makes the chain concrete: a session picker in the TUI, backed by fleet state that only the coordinator has. - -```text -session-directory-plugin/ - contract.ts upstream and downstream DTOs and service tokens - coordinator.ts scoped SessionRepo/process-manager authority - session.ts upstream consumer + narrowed downstream facade - tui.ts session picker - index.ts feature bundle -``` - -### Shared contract: two deliberately different surfaces - -The upstream directory is the coordinator's full contract. The downstream picker is the facade the session host offers its presentations — narrower on purpose: +### Coordinator host context ```ts -// upstream: provided by the coordinator host, consumed by session hosts -export interface SessionRecordSummary { +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 +} + +interface CodingAgentCoordinatorPluginContext 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: + +```ts +interface ManagedSessionRecord { sessionId: string; title: string; workspaceId: string; ownerId: string; - cwd: string; - status: "starting" | "active" | "idle" | "closed"; + cwd: string; // ownerId and cwd never leave the coordinator + status: "starting" | "active" | "idle" | "closed" | "unreachable"; updatedAt: string; } -export interface SessionDirectoryState { - revision: number; - sessions: SessionRecordSummary[]; +type ManagedSessionChange = { type: "created" | "changed" | "deleted"; record: ManagedSessionRecord }; + +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 +} +``` + +### 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. + +```ts +export interface SessionRecordSummary { + sessionId: string; + title: string; + workspaceId: string; + status: ManagedSessionRecord["status"]; + updatedAt: string; } export type SessionDirectoryEvent = - | { type: "created"; session: SessionRecordSummary } - | { type: "changed"; session: SessionRecordSummary } + | { type: "created" | "changed"; session: SessionRecordSummary } | { type: "deleted"; sessionId: string }; export interface SessionDirectoryService { - readonly state: RemoteState; + readonly state: RemoteState<{ revision: number; sessions: SessionRecordSummary[] }>; readonly events: RemoteEvents; - create(options: { title: string; workspaceId: string }, context: Context): Promise; - remove(sessionId: string, context: Context): Promise; } export const SessionDirectory = defineRemoteService("session-directory", { @@ -668,259 +518,145 @@ export const SessionDirectory = defineRemoteService("se events: ["events"], }); -// downstream: provided by the session host, consumed by presentations -export interface SessionPickerEntry { - sessionId: string; - title: string; - status: SessionRecordSummary["status"]; - updatedAt: string; - isCurrent: boolean; -} - -export interface SessionPickerState { - revision: number; - sessions: SessionPickerEntry[]; -} - -export type SessionPickerEvent = - | { type: "created"; session: SessionPickerEntry } - | { type: "changed"; session: SessionPickerEntry } - | { type: "deleted"; sessionId: string }; - -export interface SessionPickerService { - readonly state: RemoteState; - readonly events: RemoteEvents; - create(options: { title: string }, context: Context): Promise; +export interface SessionManagementService { + 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 SessionPicker = defineRemoteService("session-picker", { - state: ["state"], - events: ["events"], -}); +export const SessionManagement = defineRemoteService("session-management"); ``` -`ownerId`, `cwd`, and `workspaceId` exist only in the upstream contract. They may cross from coordinator to session host; they never reach a presentation host. - -### Coordinator facet: own the authoritative directory - -The coordinator host follows the same pattern as the session host — the kernel stays domain-blind, and the host context grants scoped local fleet authority plus the familiar service/state infrastructure: - -```ts -interface CodingAgentCoordinatorPluginContext extends PluginLifecycleScope { - // scoped local fleet authority - readonly fleet: FleetPluginScope; // scoped SessionRepo and process-manager views - - // plus the same coordinator-host-owned provide/use/remoteState/remoteEvents - // and connection callbacks as the session host context -} -``` - -The reserved list mirrors the session host: the raw `SessionRepo`, storage handles, and unrestricted process-kill authority stay with the coordinator application; `fleet` exposes narrowed, scope-owned views. +### Coordinator facet ```ts // coordinator.ts export function sessionDirectoryCoordinatorFacet(pluginContext: CodingAgentCoordinatorPluginContext) { - const records = pluginContext.fleet.records; // scoped SessionRepo view - const processes = pluginContext.fleet.processes; // scoped process-manager view - - const state = pluginContext.remoteState({ revision: 0, sessions: [] }); + const { managed, attachments } = pluginContext.fleet; + const state = pluginContext.remoteState({ revision: 0, sessions: [] as SessionRecordSummary[] }); const events = pluginContext.remoteEvents(); - function apply(sessions: SessionRecordSummary[], event: SessionDirectoryEvent, context: Context) { - state.set({ revision: state.value.revision + 1, sessions }, context); - events.emit(event, context); + function publish(change: ManagedSessionChange, context: Context) { + state.set({ revision: state.value.revision + 1, sessions: managed.snapshot().map(toSummary) }, context); + events.emit(toDirectoryEvent(change), context); } - pluginContext.provide(SessionDirectory, { - state, - events, + pluginContext.own(managed.onChanged(publish)); + pluginContext.onActivate(() => + state.set({ revision: 1, sessions: managed.snapshot().map(toSummary) }, BACKGROUND_CONTEXT), + ); + + pluginContext.provide(SessionDirectory, { state, events }); + + pluginContext.provide(SessionManagement, { async create(options, context) { - // authorize against the calling session identity that coordinator - // transport policy installed server-locally; never trust arguments - const caller = requireSessionIdentity(context); - if (options.workspaceId !== caller.workspaceId) { - throw new RemoteServiceError("not_authorized", "Cannot create outside the caller's workspace"); - } - const record = await records.create(options, context); - await processes.start(record.sessionId, context); - const session = toSummary(record); - apply([...state.value.sessions, session], { type: "created", session }, context); - return session; + const client = requireClientIdentity(context); // typed value installed by transport policy + return toSummary(await managed.create({ title: options.title, workspaceId: client.workspaceId }, context)); }, async remove(sessionId, context) { - const caller = requireSessionIdentity(context); - const target = state.value.sessions.find((s) => s.sessionId === sessionId); - if (target === undefined || target.workspaceId !== caller.workspaceId) { - throw new RemoteServiceError("not_authorized", `Not removable: ${sessionId}`); - } - await processes.stop(sessionId, context); - await records.remove(sessionId, context); - apply( - state.value.sessions.filter((s) => s.sessionId !== sessionId), - { type: "deleted", sessionId }, - context, - ); + authorizeTarget(requireClientIdentity(context), managed.snapshot(), sessionId); + await managed.remove(sessionId, context); }, - }); - - pluginContext.own( - processes.onStatusChanged((record, context) => { - const session = toSummary(record); - apply( - state.value.sessions.map((s) => (s.sessionId === session.sessionId ? session : s)), - { type: "changed", session }, - context, - ); - }), - ); - - pluginContext.onActivate(async () => { - const existing = await records.list(BACKGROUND_CONTEXT); - state.set({ revision: 1, sessions: existing.map(toSummary) }, BACKGROUND_CONTEXT); - }); -} -``` - -Direct scoped capability, authoritative `RemoteState`, semantic events — the session-facet pattern one level up, plus one rule: **the coordinator authorizes every call against the session identity its transport policy installed in the server-local context** (`requireSessionIdentity(context)` reads a typed value, like `CLIENT_ID` earlier). Downstream projection in the session facade is redaction and defense in depth, never the sole authorization boundary; a compromised session process still cannot touch another workspace's sessions. Whether the coordinator additionally projects its replicated state per connection — so a session host only ever receives its own workspace's records — is an open protocol decision; methods are authorized either way. - -### Session facet: upstream consumer, downstream facade - -The session context's `upstream` grants remote `use()` over the session process's one connection to the coordinator: - -```ts -interface SessionUpstream { - use(service: RemoteService): Promise; - readonly connection: RemoteState; -} -``` - -`upstream.use()` hydrates the upstream state replica before resolving, like presentation-host `use()`. The facet then projects that replica into its own downstream service: - -```ts -// session.ts -export async function sessionDirectorySessionFacet(pluginContext: CodingAgentSessionPluginContext) { - const self = pluginContext.agent.identity; // sessionId + workspaceId - const directory = await pluginContext.upstream.use(SessionDirectory); - - const state = pluginContext.remoteState({ revision: 0, sessions: [] }); - const events = pluginContext.remoteEvents(); - - // Downstream state derives from upstream *state* deliveries. The immediate - // hydration callback populates it; each later delivery re-projects under the - // reconstructed context of the upstream set(). - pluginContext.own( - directory.state.subscribe((next, context) => { - state.set(project(next, self), context); - }), - ); - - // Semantic events forward independently under their own event context. Never - // read directory.state.value inside an event callback: state and event - // streams are separately sequenced and can race. - pluginContext.own( - directory.events.subscribe((event, context) => { - const projected = projectEvent(event, self); - if (projected !== null) events.emit(projected, context); - }), - ); - - pluginContext.provide(SessionPicker, { - state, - events, - async create(options, context) { - const created = await directory.create( - { title: options.title, workspaceId: self.workspaceId }, - context, // the session's reconstructed local context, not the TUI's - ); - return toEntry(created, self); - }, - async remove(sessionId, context) { - if (!state.value.sessions.some((s) => s.sessionId === sessionId)) { - throw new RemoteServiceError("unknown_session", `Not visible: ${sessionId}`); - } - await directory.remove(sessionId, context); + async attach(sessionId, context) { + const client = requireClientIdentity(context); + authorizeTarget(client, managed.snapshot(), sessionId); + await attachments.bind(client.clientId, sessionId, context); }, + async detach(context) { await attachments.unbind(requireClientIdentity(context).clientId, context); }, }); } -function project(upstream: SessionDirectoryState, self: SessionIdentity): SessionPickerState { - return { - revision: upstream.revision, - sessions: upstream.sessions - .filter((session) => session.workspaceId === self.workspaceId) // visibility (defense in depth) - .map((session) => toEntry(session, self)), // redaction - }; +function authorizeTarget(client: ClientIdentity, records: ManagedSessionRecord[], sessionId: string) { + const record = records.find((r) => r.sessionId === sessionId); + if (record === undefined || record.workspaceId !== client.workspaceId) { + throw new RemoteServiceError("not_authorized", `Not accessible: ${sessionId}`); + } } -function toEntry(session: SessionRecordSummary, self: SessionIdentity): SessionPickerEntry { - const { sessionId, title, status, updatedAt } = session; // ownerId and cwd stop here - return { sessionId, title, status, updatedAt, isCurrent: sessionId === self.sessionId }; +function toSummary({ sessionId, title, workspaceId, status, updatedAt }: ManagedSessionRecord): SessionRecordSummary { + return { sessionId, title, workspaceId, status, updatedAt }; // ownerId and cwd stop here } ``` -This is projection, not tunneling. Blindly forwarding the upstream proxy or replaying upstream frames onto presentation connections would be wrong on every axis: - -- **Redaction and defense in depth.** The upstream snapshot contains other sessions' `ownerId` and `cwd`. The facade decides what its attached presentations may see and strips fields before anything reaches a presentation connection — while the coordinator stays the authorization boundary for mutations. -- **Reference and subscription isolation.** Upstream remote references and subscription IDs are scoped to the session↔coordinator connection and mean nothing on a presentation connection. The facade holds exactly one upstream subscription and fans out to any number of downstream subscriptions. -- **Independent disconnects.** The two connections fail separately. If the coordinator connection drops, presentation connections stay healthy: the facade keeps serving the last projected state as stale data, may surface upstream health in its own state, and fails downstream `create`/`remove` with a stable error code. If a presentation disconnects, the upstream subscription is untouched. -- **Hydration.** A presentation that connects late gets a complete downstream snapshot re-projected from the facade's current upstream replica, regardless of when the session host itself hydrated. -- **Contract stability.** The upstream contract can grow fields without breaking presentation code; the facade absorbs the change. +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. ### TUI facet: the picker ```ts // tui.ts export async function sessionPickerTuiFacet(pluginContext: CodingAgentTuiPluginContext) { - const picker = await pluginContext.use(SessionPicker); + const directory = await pluginContext.coordinator.use(SessionDirectory); + const management = await pluginContext.coordinator.use(SessionManagement); - pluginContext.commands.register("sessions.list", async (context) => { - const entries = picker.state.value.sessions; - await pluginContext.ui.select( + pluginContext.commands.register("sessions.switch", async (context) => { + const entries = directory.state.value.sessions; + const picked = await pluginContext.ui.select( "Sessions", - entries.map((e) => `${e.isCurrent ? "* " : " "}${e.title} (${e.status})`), + entries.map((entry) => pickerLabel(entry, pluginContext.session.attachment.value)), { signal: context.abortSignal }, ); + if (picked === undefined) return; + await management.attach(entries[choiceIndex(entries, picked)].sessionId, context); }); - picker.state.subscribe((next) => renderSessionList(next)); + directory.state.subscribe((next) => renderSessionList(next)); } ``` -The TUI facet knows nothing about the coordinator. It consumes `SessionPicker` exactly the way it consumes `Models`; a web facet consumes the same token with browser UI. +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 and cancellation across two hops +### Trace 1: merged directory ```text -ui.sessions.create (TUI process) -└─ rpc.client session-picker.create (TUI → session) - └─ rpc.server session-picker.create (session host: fresh local Context) - └─ rpc.client session-directory.create (session → coordinator) - └─ rpc.server session-directory.create (coordinator host: fresh local Context) - └─ coordinator.sessions.create (SessionRepo write + process start) +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 hop reconstructs a local `Context` — a request-scoped abort controller plus a telemetry parent extracted from the trace carrier — and no `Context` object ever crosses the wire. What chains the hops is that the session facade passes its reconstructed local context to the upstream proxy: aborting the TUI request cancels the TUI→session request, which aborts the session's local context, which cancels the session→coordinator request, which aborts the coordinator's local context. State updates and events flow back down the chain carrying source trace metadata at every hop. +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. -## Reverse interactions: the question plugin end-to-end +### Attaching and switching -Some session-side work must ask a connected presentation host for a decision. The existing `examples/extensions/question.ts` demonstrates the user experience: the model calls a `question` tool, the user selects an option or types a custom answer, and the tool returns that answer to the model with a compact custom rendering. +`attach(sessionId)` selects the session for this presentation connection: -In the facet architecture, tool execution stays in the session host and the dialog stays in a presentation host. The bridge is a typed semantic interaction, not a serialized callback or component. +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`. + +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 + +```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 +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 +``` + +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. + +### Forwarding 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 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. + +## Reverse 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. ### Shared contract -The shared file contains only JSON-safe tool and interaction data: - ```ts -interface QuestionOption { - label: string; - description: string | null; -} - interface QuestionRequest { question: string; - options: QuestionOption[]; + options: Array<{ label: string; description: string | null }>; } type QuestionResponse = @@ -938,31 +674,26 @@ interface QuestionDetails { const QuestionInteraction = defineInteraction("question"); ``` -`defineInteraction()` is a typed token much like a service token. The session interaction broker routes one request to an eligible connected handler and returns its response: +`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: ```ts interface SessionInteractions { - request( - interaction: Interaction, - request: Request, - context: Context, - ): Promise; + request( + interaction: Interaction, request: Req, context: Context, + ): Promise; } interface PresentationInteractions { - handle( - interaction: Interaction, - handler: (request: Request, context: Context) => Promise, + handle( + interaction: Interaction, handler: (request: Req, context: Context) => Promise, ): () => void; } ``` -The broker owns request IDs, eligible-client selection, cancellation, disconnect handling, and response validation. The definition contains no TUI concepts. +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. ### Session facet: contribute the tool -The session facet contributes an ordinary model-callable tool. The illustrative tool contribution API adapts this execution function to the final `AgentHarnessTool` contract: - ```ts // session.ts export function questionSessionFacet(pluginContext: CodingAgentSessionPluginContext) { @@ -974,93 +705,51 @@ export function questionSessionFacet(pluginContext: CodingAgentSessionPluginCont parameters: QuestionParamsSchema, // typebox schema matching QuestionRequest async execute(params: QuestionRequest, context: Context) { - if (params.options.length === 0) { - return questionResult(params, null, false, "No options provided"); - } - - const response = await pluginContext.interactions.request( - QuestionInteraction, - 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}`); - } - + if (params.options.length === 0) return questionResult(params, null, false, "No options provided"); + const response = await pluginContext.interactions.request(QuestionInteraction, 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]; if (selected === undefined) throw new Error("Question response selected an invalid option"); - return questionResult( - params, - selected.label, - false, - `User selected: ${response.index + 1}. ${selected.label}`, - ); + return questionResult(params, selected.label, false, `User selected: ${response.index + 1}. ${selected.label}`); }, }); }); } -function questionResult( - request: QuestionRequest, - answer: string | null, - wasCustom: boolean, - text: string, -): AgentToolResult { +function questionResult(request: QuestionRequest, answer: string | null, wasCustom: boolean, text: string) { return { content: [{ type: "text", text }], - details: { - question: request.question, - options: request.options.map((option) => option.label), - answer, - wasCustom, - }, - }; + details: { question: request.question, options: request.options.map((o) => o.label), answer, wasCustom }, + } satisfies AgentToolResult; } ``` -The session facet knows that an answer is required for tool execution, but knows nothing about terminal keys, dialogs, overlays, or rendering. It receives only the semantic `QuestionResponse`. +The session facet knows an answer is required; it knows nothing about terminal keys, dialogs, or rendering. It receives only the semantic `QuestionResponse`. -### TUI facet: present the dialog, contribute the renderer - -The TUI facet registers the handler that presents the question. This compact version uses the TUI host's select and input dialogs; a richer version may substitute the inline editor component from `examples/extensions/question.ts` without changing the shared contract or the session facet. +### TUI facet: dialog and renderer ```ts // tui.ts export function questionTuiFacet(pluginContext: CodingAgentTuiPluginContext) { pluginContext.interactions.handle(QuestionInteraction, async (request, context) => { const choices = [ - ...request.options.map((option, index) => - `${index + 1}. ${option.label}${option.description ? ` — ${option.description}` : ""}`, - ), + ...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, - }); + const choice = await pluginContext.ui.select(request.question, choices, { signal: context.abortSignal }); if (choice === undefined) return { outcome: "cancelled" }; - const selectedIndex = choices.indexOf(choice); - if (selectedIndex < request.options.length) { - return { outcome: "selected", index: selectedIndex }; - } + 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" }; + 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: QuestionOption, i: number) => `${i + 1}. ${o.label}`).join(", "); + 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); }, @@ -1073,117 +762,37 @@ export function questionTuiFacet(pluginContext: CodingAgentTuiPluginContext) { } ``` -### Loopback bundle and future facets +### Bundle and future facets -`index.ts` assembles the loopback bundle for single-process embedding; multi-process hosts resolve the `./session` or `./tui` entry points directly and never load the other modules: - -```ts -export const questionPlugin = definePlugin({ - id: "@pi/question", - session: questionSessionFacet, - tui: questionTuiFacet, - // web: questionWebFacet, once a web host exists -}); -``` - -The runtime flow matches the bird's-eye trace: the broker picks a connected presentation host with a question handler, validates the JSON response, and the session facet builds the durable tool result. No UI component or callback crosses the process boundary. A future web facet completes the example without touching session code: - -```ts -// web.ts -export function questionWebFacet(pluginContext: CodingAgentWebPluginContext) { - pluginContext.interactions.handle(QuestionInteraction, async (request, context) => { - const response = await showQuestionModal(request, { signal: context.abortSignal }); - return response ?? { outcome: "cancelled" }; - }); - pluginContext.views.addToolView("question", QuestionToolView); -} -``` - -The contract, broker, and session facet are unchanged; only presentation differs per host. An IDE host can use a native quick-pick; a headless host may decline the interaction. - -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 through an OAuth-specific interaction; -- editor and question plugins exchange semantic text, selections, or cancellation rather than component trees. - -Secrets need an explicitly sensitive interaction contract. Secret responses must never enter replicated state, logs, events, or telemetry attributes. +`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. ## Lifecycle and disposal -Every facet-owned resource — service registrations, contributions, state subscriptions, watchers, timers, in-flight RPCs, subprocesses, screens, overlays — is owned by the facet's kernel scope in its host and disposed when that scope closes. Scopes dispose in reverse manifest order. The scoped capabilities in `AgentPluginScope` enforce this automatically for hooks and event subscriptions; `onDispose` handles the rest (aborting a facet's session-lifetime `AbortController`, stopping watchers, closing subprocesses). - -Removing a plugin therefore has two effects in every host that loaded one of its facets: the facet's scope disposes, and any contribution registries it touched rebuild without it. Neither requires the plugin to undo anything by hand. +Every facet-owned resource — service registrations, contributions, state subscriptions, watchers, timers, in-flight RPCs, subprocesses, overlays — is owned by the facet's kernel scope and disposed when that scope closes, in reverse manifest order. Scoped capabilities enforce this automatically for hooks and event subscriptions; `onDispose` handles the rest. Removing a plugin disposes its scope in every host that loaded a facet and rebuilds any contribution registries it touched; neither requires the plugin to undo anything by hand. ## Connection loss, errors, and security -Connection behavior, from the plugin author's perspective: +Disconnect behavior, from the plugin author's perspective: -- A client disconnect aborts that client's active RPC requests and removes its subscriptions. The session stays alive per application policy. -- The client keeps its last replicated state as stale display data and observes the disconnected connection state; facets should disable commands that require an authoritative session while disconnected. -- A new connection hydrates from a fresh, complete authoritative snapshot. Remote object references from the old connection are invalid unless the exposure explicitly has session-stable identity and the client reacquires them. -- **Never blindly replay a mutation after an uncertain disconnect.** The session may have applied it before the response was lost — 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. +- **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. +- 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 with stable codes: +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. -```ts -interface RemoteError { - code: string; - message: string; - data?: JsonValue; -} -``` - -Expected service errors use stable codes or result values; unexpected exceptions become an internal error with safe metadata. Stacks and sensitive causes are not sent by default. Cancellation, disconnect, unknown service/method, invalid arguments/results, and stale references need distinct codes. - -Security rules every providing host's application (session and coordinator alike) must enforce: +Security rules every providing host (session and coordinator 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-compatible, and protocol envelopes cannot be forged as ordinary values; +- 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, provider headers, 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 perform authorization server-side even when the TypeScript client surface hides them. +- 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**. ## The coding agent as a plugin manifest -Putting it together: 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. - -Expected coordinator facets: - -- session record (`SessionRepo`) and directory authority; -- session process spawn/attach/stop policy and health reporting; -- fleet-level authentication and client authorization. - -Expected session facets: - -- durable session creation/restoration and the scoped façade over the real Harness; -- provider contributions, model catalogues, model selection authority, and authentication; -- tool contributions, wrappers, permissions, and execution support; -- prompt, steer, follow-up, abort, compaction, and navigation services; -- transcript/custom-entry persistence and semantic event projection; -- filesystem, subprocess, terminal, and watcher effects; -- permission, question, editor, secret, and OAuth interaction requests; -- session lifecycle and unattended-session policy; -- upstream coordinator consumption and downstream facades such as the session picker. - -Expected TUI facets: - -- chat screen, transcript rendering, editor, and prompt input; -- slash commands, autocomplete, and keybindings; -- model picker and thinking-level controls; -- tool, message, and custom-entry renderers; -- screens, typed slots, dialogs, overlays, and interaction presentation; -- themes, focus, notifications, animation, and terminal-specific components. - -A web host carries analogous browser facets: routes, views, DOM dialogs, and web renderers. - -Shared contracts: - -- service tokens and JSON-safe DTOs; -- RPC method signatures, latest-value state, and semantic event types; -- semantic interaction request/response types; -- screen/slot tokens used only for presentation-local composition; -- 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. 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 generic kernel knows none of these domain concepts; each app host knows only its own. @@ -1191,31 +800,26 @@ 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) and the built-in plugin manifest; -- the concrete coordinator, session, and TUI host context surfaces, and how much shape contexts share by convention; -- the logical-manifest format, per-host entry-point resolution (export conditions versus descriptor fields), and cross-process version pinning; -- whether the coordinator projects replicated state per connection (workspace-scoped snapshots) or relies on method authorization plus session-side redaction; -- upstream infrastructure: whether a standalone deployment runs a loopback in-process coordinator host or an absent `upstream`, and how facades must degrade in the latter case; -- staleness signaling and stable error codes for chained facades while the upstream connection is down; -- the coding-agent scoped Harness façade exposed to session plugins; -- contribution APIs for providers, tools, renderers, screens, and typed slots, and the boundary between a generic replay-registry utility and coding-agent draft semantics; -- the exact `RemoteService`/exposure-descriptor API and whether method manifests are explicit, generated, or discovered from trusted implementations; -- the context-position and JSON-safe optional-argument policy from `rpc.md`; -- whether `RemoteState` is generic RPC infrastructure or session-host infrastructure, and snapshot granularity (all services per connection versus independently subscribable groups), plus the exact hydration/delivery-context semantics for state subscriptions; -- state/event flow control and replay policy; object-reference scope and garbage collection; -- the stable error envelope and expected-error registration; -- plugin activation/disposal ordering, optional dependencies, and demand-subscription/shared-proxy lifetime; -- reverse-interaction routing and eligible-client policy; -- plugin/service protocol version negotiation and client authorization; +- 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 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; +- the standalone/loopback story: an in-process coordinator host versus an absent coordinator, and how `coordinator.use()` degrades; +- 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; +- 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; +- 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; - package boundaries between the generic kernel, agent plugin contracts, generic protocol machinery, and coding-agent host integration. ## Testing strategy The handoff should include a reusable two-transport test matrix (loopback plus a real framed transport) covering: -- **Composition:** the coding-agent hosts start from one plugin manifest, not hard-coded features; per-host entry-point resolution from one logical manifest, with no session modules reachable in a presentation bundle; provide/demand-resolve round trip; shared proxy/replica across concurrent consumers; local services unreachable from presentation hosts; duplicate/missing service failures; activation only after registration; reverse-order disposal. +- **Composition:** hosts start from one plugin manifest, not hard-coded features; per-host entry-point resolution with no session modules reachable in a presentation bundle; 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. - **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; state and event deliveries carry reconstructed source contexts to client listeners; immediate snapshot callbacks run under the defined hydration context. +- **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. -- **Chaining:** downstream snapshots and events contain no upstream-only fields; one upstream subscription fans out to N downstream subscriptions; upstream disconnect leaves presentation connections healthy, serves stale projected state, and fails downstream mutations with stable codes; downstream disconnect leaves the upstream subscription intact; downstream state derives from upstream state deliveries with no state/event race; the coordinator rejects cross-workspace calls even from a misbehaving session host; two-hop cancellation (TUI abort cancels the session→coordinator request) and two-hop trace parentage. -- **Boundaries:** scoped agent and fleet capabilities cannot reach host ownership or whole-registry mutation; raw Harness/Session/SessionRepo capabilities unreachable from downstream connections; 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. +- **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.