feat(coding-agent): run experimental client as direct chat

Replace raw Harness and Chat service exposure with AgentController and replicated lane state. Start directly in new or resumed sessions and persist model selection.
This commit is contained in:
Armin Ronacher 2026-08-27 15:57:55 +02:00
parent 35fb116a6a
commit 4bd9438e5d
26 changed files with 1335 additions and 769 deletions

View file

@ -355,30 +355,22 @@ const Tui = defineService<TuiHost>("pi.local.tui", { rpc: false });
The TUI loads all of its facets into one generation. Its host routes `env.use(SessionDirectory)` to the connected server and `env.use(Models)` to the selected Session. While detached, Session calls fail with `session_not_attached` and replicated state has no value. Connection and attachment health are host-local services because they describe presentation control state. A future web host similarly binds local services for routes, views, and DOM dialogs. Its server and Session facets still use unqualified service operations.
The minimum `Chat` service exposes `prompt(request, context)` returning `{ accepted, operationId, error }`, plus `requestAbort(operationId, context)`. Its Session facet delegates through a process-local scoped Agent service. Additional queue, navigation, resume, and compaction operations should be added only with concrete presentation requirements.
`AgentController` is the presentation-safe command facade over the worker-owned main `AgentLane`. It exposes prompt, queue, abort, resume, compaction, and navigation operations as JSON-safe results. The Session runtime constructs it directly from the lane; it does not publish the raw Harness or lane as local facet services.
The scoped form is:
The runtime form is:
```ts
export const chatSessionFacet = defineFacet({
id: "@pi/chat",
setup(env) {
const agent = env.use(Agent);
env.provide(Chat, {
async prompt(request, context) {
const lane = await agent.lane("main", context);
return toPromptResponse(await lane.prompt(request.message, context));
},
async requestAbort(operationId, context) {
const lane = await agent.lane("main", context);
await lane.requestAbort(operationId, context);
},
});
},
});
export function createAgentControllerRuntimeFacet(lane: AgentLane) {
return defineFacet({
id: "@pi/agent-controller-runtime",
setup(env) {
env.provide(AgentController, createAgentController(lane));
},
});
}
```
Its TUI facet consumes `Chat` through `env.use()` exactly as the model picker consumes `Models`. Neither reveals the Harness object behind them; there is no universal remote Harness for arbitrary plugins. `rpc.md` may still define generic Harness proxies for other trusted integrations (an IDE bridge, an orchestrator) — deliberate, separate exposures, not the plugin boundary.
Its TUI facet consumes `AgentController` through `env.use()` exactly as the model picker consumes `Models`. It does not reveal the Harness object behind the controller; 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.
## Local services and narrow remote facades
@ -674,9 +666,9 @@ Session service handles are stable across switches: a proxy returned once by a S
### Routed session call
```text
TUI A (selected session S1): rpc.client chat.prompt
TUI A (selected session S1): rpc.client agent-controller.prompt
server: authorize client for S1; route to session worker S1 with authenticated client identity
S1: rpc.server chat.prompt — fresh local Context, validated JSON args → lane.prompt(...)
S1: rpc.server agent-controller.prompt — fresh local Context, validated JSON args → lane.prompt(...)
response returns S1 → server → TUI A
```

View file

@ -223,7 +223,7 @@ Remaining cleanup:
These are not blockers for the durable Harness:
- standalone S3 search after its API decisions;
- Accounts removal, revisioned Transcript production, and Chat steer/follow-up/next-run/cancel/resume/compact/navigate service adapters;
- Accounts removal and revisioned Transcript production;
- authenticated workspace/client authorization for the experimental local server;
- private returned references, service flow control, multi-pane presentation, plugin kernel/reload completion, and version-skew negotiation;
- administrative precise rewrite tooling;

View file

@ -552,7 +552,7 @@ Public drive remains disabled. Complete this package before resuming WP05 M4.
- all runtime drive modules already present (`checkpoint`, `generation`, `recovery`, `terminal`, `progress`) only where names/signatures change.
- `src/harness/compaction/branch-summarization.ts`.
- `packages/protocol/src/harness.ts`.
- `packages/coding-agent/src/experimental/services/chat-provider.ts`.
- `packages/coding-agent/src/experimental/services/agent-controller-provider.ts`.
- `packages/coding-agent/src/experimental/services/models-provider.ts`.
- `packages/coding-agent/src/experimental/session-worker.ts`.
- experimental harness wire/session worker tests.

View file

@ -15,6 +15,8 @@ export interface ClientCommand {
readonly auth?: AuthInput;
readonly connect?: TransportAddress;
readonly sessionId?: string;
readonly continue?: boolean;
readonly resume?: boolean;
readonly provider?: string;
readonly model?: string;
readonly prompt?: string;
@ -49,11 +51,20 @@ export const clientCommand = new Command<ClientCommand, ClientCommandContext>("c
promptArgs[0]!.length > 0
? promptArgs[0]
: undefined;
const { errors: optionErrors } = parseLegacyOptions(input);
const { options, errors: optionErrors } = parseLegacyOptions(input);
const modelErrors = provider !== undefined && model === undefined ? ["--provider requires --model"] : [];
const sessionSelectionErrors =
[sessionId !== undefined, options.continue === true, options.resume === true].filter(Boolean).length > 1
? ["--session-id, --continue, and --resume are mutually exclusive"]
: [];
const onlySessionSelectionOptions =
input.remainingArgs.length > 0 &&
input.remainingArgs.every((arg) => arg === "--continue" || arg === "-c" || arg === "--resume" || arg === "-r");
const unsupportedErrors =
input.remainingArgs.length === 0 || prompt !== undefined ? [] : unsupportedLegacyOptions("client", input);
const errors = [...authErrors, ...optionErrors, ...modelErrors, ...unsupportedErrors];
input.remainingArgs.length === 0 || prompt !== undefined || onlySessionSelectionOptions
? []
: unsupportedLegacyOptions("client", input);
const errors = [...authErrors, ...optionErrors, ...modelErrors, ...sessionSelectionErrors, ...unsupportedErrors];
if (errors.length > 0) return { ok: false, errors };
return {
ok: true,
@ -62,6 +73,8 @@ export const clientCommand = new Command<ClientCommand, ClientCommandContext>("c
...(auth === undefined ? {} : { auth }),
...(connect === undefined ? {} : { connect }),
...(sessionId === undefined ? {} : { sessionId }),
...(options.continue === true ? { continue: true } : {}),
...(options.resume === true ? { resume: true } : {}),
...(provider === undefined ? {} : { provider }),
...(model === undefined ? {} : { model }),
...(prompt === undefined ? {} : { prompt }),

View file

@ -5,7 +5,7 @@ import { createUnixTransportFactory, discoverUnixServers, type UnixServerRoute }
import { isServerId } from "@earendil-works/pi-protocol";
import type { ClientCommand } from "../cli/experimental/commands/client.ts";
import { activateServer, ENV_SERVER_ID, resolveServerDirectory, resolveSessionDirectory } from "./server.ts";
import { Chat } from "./services/chat.ts";
import { AgentController } from "./services/agent-controller.ts";
import {
createServerServiceConnection,
createSessionServiceConnection,
@ -26,7 +26,7 @@ export interface ActivatedClientRuntimeServer extends ClientRuntimeServer {
readonly directory: SessionDirectory;
readonly management: SessionManagement;
readonly models: Models;
readonly chat: Chat;
readonly agent: AgentController;
}
export interface ClientRuntime {
@ -128,7 +128,7 @@ export async function activateBuiltinClientServices(
onError() {},
});
const sessionServices = server.session.open({
services: [Models, Chat],
services: [Models, AgentController],
assertAccess() {},
onError() {},
});
@ -151,9 +151,9 @@ export async function activateBuiltinClientServices(
},
};
const models = sessionServices.use(Models);
const chat = sessionServices.use(Chat);
const agent = sessionServices.use(AgentController);
await Promise.all([serverServices.activate(BACKGROUND_CONTEXT), sessionServices.activate(BACKGROUND_CONTEXT)]);
return { ...server, directory, management, models, chat };
return { ...server, directory, management, models, agent };
}
function routeFromExplicitPath(path: string): UnixServerRoute {

View file

@ -0,0 +1,174 @@
import type { AgentMessage, Entry, LaneSnapshot } from "@earendil-works/pi-agent-core";
import type { AssistantMessage } from "@earendil-works/pi-ai";
import { Container, Spacer, Text, TruncatedText, type TUI } from "@earendil-works/pi-tui";
import { createAllToolRenderers } from "../core/tools/renderers/index.ts";
import { AssistantMessageComponent } from "../modes/interactive/components/assistant-message.ts";
import { type StatusIndicator, WorkingStatusIndicator } from "../modes/interactive/components/status-indicator.ts";
import { ToolExecutionComponent, type ToolRenderers } from "../modes/interactive/components/tool-execution.ts";
import { UserMessageComponent } from "../modes/interactive/components/user-message.ts";
import { theme } from "../modes/interactive/theme/theme.ts";
function userMessageText(message: AgentMessage): string {
if (message.role !== "user") return "";
if (typeof message.content === "string") return message.content;
return message.content
.filter((content) => content.type === "text")
.map((content) => content.text)
.join("");
}
/** Snapshot-driven transcript used by the service-only experimental presentation. */
export class ExperimentalChatView extends Container {
static readonly #renderers: Record<string, ToolRenderers> = createAllToolRenderers();
readonly #ui: TUI;
readonly #cwd: string;
readonly #chat = new Container();
readonly #queue = new Container();
readonly #status = new Container();
readonly #tools = new Map<string, ToolExecutionComponent>();
#renderedEntryIds: string[] = [];
#streaming: AssistantMessageComponent | undefined;
#indicator: StatusIndicator | undefined;
#working = false;
constructor(ui: TUI, cwd: string) {
super();
this.#ui = ui;
this.#cwd = cwd;
this.addChild(this.#chat);
this.addChild(this.#queue);
this.addChild(this.#status);
}
apply(snapshot: LaneSnapshot): void {
this.#syncTranscript(snapshot.transcript);
this.#syncStreaming(snapshot.operation?.streamingMessage);
for (const tool of snapshot.operation?.runningTools ?? []) {
const component = this.#tool(tool.toolName, tool.toolCallId, tool.args);
component.markExecutionStarted();
if (tool.partialResult) {
component.updateResult(
{ content: tool.partialResult.content, details: tool.partialResult.details, isError: false },
true,
);
}
}
this.#syncQueues(snapshot.queues);
this.#setWorking(snapshot.operation !== null);
this.invalidate();
}
dispose(): void {
this.#indicator?.dispose();
}
#syncQueues(queues: LaneSnapshot["queues"]): void {
this.#queue.clear();
for (const item of queues) {
const text =
item.type === "message" ? userMessageText(item.message).replace(/\s+/g, " ") : `<${item.customType}>`;
this.#queue.addChild(new TruncatedText(theme.fg("muted", `[${item.kind}] ${text}`), 1, 0));
}
}
#syncTranscript(transcript: readonly Entry[]): void {
const diverged = this.#renderedEntryIds.some((id, index) => transcript[index]?.id !== id);
if (diverged) {
this.#chat.clear();
this.#tools.clear();
this.#renderedEntryIds = [];
this.#streaming = undefined;
}
for (const entry of transcript.slice(this.#renderedEntryIds.length)) {
this.#addEntry(entry);
this.#renderedEntryIds.push(entry.id);
}
}
#addEntry(entry: Entry): void {
if (entry.type === "compaction") {
this.#addText(theme.fg("muted", `[compaction] compacted from ${entry.tokensBefore} tokens`));
for (const retained of entry.retainedTail) this.#addMessage(retained);
return;
}
if (entry.type === "branch_summary") {
this.#addText(theme.fg("muted", "[branch summary]"));
this.#addText(entry.summary);
return;
}
if (entry.type === "custom") {
this.#addText(theme.fg("muted", `[${entry.customType}]`));
return;
}
this.#addMessage(entry.message);
}
#addMessage(message: AgentMessage): void {
if (message.role === "user") {
this.#chat.addChild(new Spacer(1));
this.#chat.addChild(new UserMessageComponent(userMessageText(message)));
return;
}
if (message.role === "assistant") {
const component = this.#streaming ?? new AssistantMessageComponent();
if (!this.#streaming) this.#chat.addChild(component);
this.#streaming = undefined;
component.updateContent(message, false);
for (const content of message.content) {
if (content.type === "toolCall") this.#tool(content.name, content.id, content.arguments).setArgsComplete();
}
return;
}
if (message.role === "toolResult") this.#tool(message.toolName, message.toolCallId).updateResult(message);
}
#syncStreaming(message: AssistantMessage | undefined): void {
if (!message) return;
if (!this.#streaming) {
this.#streaming = new AssistantMessageComponent();
this.#chat.addChild(this.#streaming);
}
this.#streaming.updateContent(message, true);
for (const content of message.content) {
if (content.type === "toolCall") this.#tool(content.name, content.id, content.arguments);
}
}
#tool(toolName: string, toolCallId: string, args?: unknown): ToolExecutionComponent {
const existing = this.#tools.get(toolCallId);
if (existing) {
if (args !== undefined) existing.updateArgs(args);
return existing;
}
const component = new ToolExecutionComponent(
toolName,
toolCallId,
args ?? {},
{},
ExperimentalChatView.#renderers[toolName],
this.#ui,
this.#cwd,
);
this.#chat.addChild(component);
this.#tools.set(toolCallId, component);
return component;
}
#addText(text: string): void {
this.#chat.addChild(new Spacer(1));
this.#chat.addChild(new Text(text, 1, 0));
}
#setWorking(working: boolean): void {
if (working === this.#working) return;
this.#working = working;
this.#indicator?.dispose();
this.#indicator = undefined;
this.#status.clear();
if (working) {
this.#indicator = new WorkingStatusIndicator(this.#ui, "Working... (esc to abort)");
this.#status.addChild(this.#indicator);
}
}
}

View file

@ -3,19 +3,26 @@ import type { SessionSummary } from "@earendil-works/pi-protocol";
import {
type Component,
Container,
Input,
ProcessTerminal,
type SelectItem,
SelectList,
setKeybindings,
Text,
type TUI,
TuiMainScreen,
} from "@earendil-works/pi-tui";
import chalk from "chalk";
import type { ClientCommand } from "../cli/experimental/commands/client.ts";
import { getAgentDir } from "../config.ts";
import { KeybindingsManager } from "../core/keybindings.ts";
import { CustomEditor } from "../modes/interactive/components/custom-editor.ts";
import { getEditorTheme, initTheme, theme } from "../modes/interactive/theme/theme.ts";
import { type OpenClientRuntimeOptions, openClientRuntime } from "./client-runtime.ts";
import { ExperimentalChatView } from "./client-tui-chat.ts";
import { combineFacetLoaders, createStaticFacetLoader, type FacetLoader, type LoadedFacets } from "./facet-loader.ts";
import { createFacetHost, defineFacet, type FacetHost } from "./facets.ts";
import { Chat } from "./services/chat.ts";
import { type LaneReplica, type LaneWatchSource, openLaneReplica } from "./lane-replica.ts";
import { AgentController, type AgentOperationResponse, type AgentQueueResponse } from "./services/agent-controller.ts";
import type { ServerServiceConnection, SessionServiceConnection } from "./services/connection.ts";
import { Models } from "./services/models.ts";
import { SessionDirectory, SessionManagement } from "./services/sessions.ts";
@ -27,15 +34,17 @@ export interface RunClientTuiOptions extends OpenClientRuntimeOptions {
export interface ClientTuiServer {
readonly serverId: string;
readonly laneWatches: LaneWatchSource;
readonly server: ServerServiceConnection;
readonly session: SessionServiceConnection;
}
interface SessionPickerFeature {
interface SessionFeature {
readonly serverId: string;
readonly directory: SessionDirectory;
readonly management: SessionManagement;
readonly session: SessionServiceConnection;
readonly laneWatches: LaneWatchSource;
}
interface ModelSelectionFeature {
@ -43,17 +52,15 @@ interface ModelSelectionFeature {
readonly models: Models;
}
interface ChatFeature {
interface AgentFeature {
readonly serverId: string;
readonly chat: Chat;
readonly controller: AgentController;
}
type ClientTuiAction =
| { readonly kind: "attach"; readonly feature: SessionPickerFeature; readonly sessionId: string }
| { readonly kind: "create"; readonly feature: SessionPickerFeature }
| { readonly kind: "model"; readonly provider: string; readonly modelId: string }
| { readonly kind: "sessions" }
| { readonly kind: "quit" };
interface ModelAction {
readonly provider: string;
readonly modelId: string;
}
const selectTheme = {
selectedPrefix: (text: string) => chalk.cyan(text),
@ -63,14 +70,14 @@ const selectTheme = {
noMatch: (text: string) => chalk.yellow(text),
};
export const sessionPickerTuiFacet = defineFacet({
id: "@pi/session-picker",
export const sessionsTuiFacet = defineFacet({
id: "@pi/sessions",
setup(env) {
const tui = env.use(Tui);
const directory = env.use(SessionDirectory);
const management = env.use(SessionManagement);
env.onActivate(() => {
env.own(tui.registerSessionPicker(directory, management));
env.own(tui.registerSessions(directory, management));
env.own(directory.state.subscribe(tui.refresh));
});
},
@ -88,61 +95,82 @@ export const modelSelectionTuiFacet = defineFacet({
},
});
export const chatTuiFacet = defineFacet({
id: "@pi/chat-input",
export const agentControllerTuiFacet = defineFacet({
id: "@pi/agent-controller",
setup(env) {
const tui = env.use(Tui);
const chat = env.use(Chat);
env.onActivate(() => env.own(tui.registerChat(chat)));
const controller = env.use(AgentController);
env.onActivate(() => env.own(tui.registerAgentController(controller)));
},
});
const BUILTIN_TUI_FACET_LOADER = createStaticFacetLoader([sessionPickerTuiFacet, modelSelectionTuiFacet, chatTuiFacet]);
const BUILTIN_TUI_FACET_LOADER = createStaticFacetLoader([
sessionsTuiFacet,
modelSelectionTuiFacet,
agentControllerTuiFacet,
]);
/** Minimal service-only presentation for Session selection, model selection, and turns. */
/** Service-only presentation driven by a replicated main-lane snapshot. */
export class ExperimentalClientTui implements Component {
readonly #ui: TUI;
readonly #requestRender: () => void;
readonly #finish: () => void;
readonly #container = new Container();
readonly #loadedFacets: LoadedFacets;
readonly #facetHosts: FacetHost[] = [];
readonly #sessionPickers = new Map<string, SessionPickerFeature>();
readonly #sessions = new Map<string, SessionFeature>();
readonly #modelSelections = new Map<string, ModelSelectionFeature>();
readonly #chats = new Map<string, ChatFeature>();
readonly #actions = new Map<string, ClientTuiAction>();
readonly #chatInput = new Input();
readonly #agents = new Map<string, AgentFeature>();
readonly #modelActions = new Map<string, ModelAction>();
readonly #chatInput: CustomEditor;
#selectList: SelectList | undefined;
#screen: "sessions" | "models" | "chat" = "sessions";
#screen: "models" | "chat" = "chat";
#selectedServerId: string | undefined;
#status = "Select a Session or create one.";
#status = "Starting Session…";
#busy = false;
#closePromise: Promise<void> | undefined;
#laneReplica: LaneReplica | undefined;
#laneUnsubscribe: (() => void) | undefined;
#chatView: ExperimentalChatView | undefined;
private constructor(requestRender: () => void, finish: () => void, loadedFacets: LoadedFacets) {
private constructor(ui: TUI, requestRender: () => void, finish: () => void, loadedFacets: LoadedFacets) {
this.#ui = ui;
this.#requestRender = requestRender;
this.#finish = finish;
this.#loadedFacets = loadedFacets;
const keybindings = KeybindingsManager.create();
setKeybindings(keybindings);
this.#chatInput = new CustomEditor(ui, getEditorTheme(), keybindings, { paddingX: 1 });
this.#chatInput.onSubmit = (message) => void this.#runPrompt(message);
this.#chatInput.onEscape = () => {
this.#screen = "models";
this.#status = "Select a model.";
this.#rebuild();
};
this.#chatInput.onEscape = () => this.#interrupt();
this.#chatInput.onCtrlD = finish;
this.#chatInput.onAction("app.clear", finish);
this.#chatInput.onAction("app.model.select", () => this.#showModels());
this.#chatInput.onAction("app.message.followUp", () => {
const text = this.#chatInput.getText().trim();
if (text.length === 0) return;
this.#chatInput.setText("");
void this.#queueFollowUp(text);
});
}
static async create(options: {
readonly command: ClientCommand;
readonly ui: TUI;
readonly servers: readonly ClientTuiServer[];
readonly facetLoader?: FacetLoader;
requestRender(): void;
finish(): void;
}): Promise<ExperimentalClientTui> {
initTheme();
const loadedFacets = await combineFacetLoaders([
BUILTIN_TUI_FACET_LOADER,
...(options.facetLoader === undefined ? [] : [options.facetLoader]),
]).load();
const component = new ExperimentalClientTui(options.requestRender, options.finish, loadedFacets);
const component = new ExperimentalClientTui(options.ui, options.requestRender, options.finish, loadedFacets);
try {
await component.#start(options.servers);
await component.#openInitialSession(options.command);
return component;
} catch (error) {
try {
@ -187,29 +215,18 @@ export class ExperimentalClientTui implements Component {
id: "@pi/tui-runtime",
setup: (env) => {
env.provide(Tui, {
registerSessionPicker: (directory, management) => {
const unregister = this.#register(this.#sessionPickers, "Session picker", {
registerSessions: (directory, management) =>
this.#register(this.#sessions, "Sessions", {
serverId: server.serverId,
directory,
management,
session: server.session,
});
const unsubscribe = server.session.attachment.subscribe(() => this.#rebuild());
return () => {
unsubscribe();
unregister();
};
},
laneWatches: server.laneWatches,
}),
registerModelSelection: (models) =>
this.#register(this.#modelSelections, "Model selection", {
serverId: server.serverId,
models,
}),
registerChat: (chat) =>
this.#register(this.#chats, "Chat", {
serverId: server.serverId,
chat,
}),
this.#register(this.#modelSelections, "Model selection", { serverId: server.serverId, models }),
registerAgentController: (controller) =>
this.#register(this.#agents, "Agent controller", { serverId: server.serverId, controller }),
refresh: () => this.#rebuild(),
setStatus: (status) => {
this.#status = status;
@ -224,17 +241,74 @@ export class ExperimentalClientTui implements Component {
});
this.#facetHosts.push(facetHost);
}
}
async #openInitialSession(command: ClientCommand): Promise<void> {
const features = [...this.#sessions.values()];
if (features.length === 0) throw new Error("No Session service is available");
let selected: { feature: SessionFeature; summary: SessionSummary } | undefined;
if (command.sessionId !== undefined) {
const matches = features.flatMap((feature) =>
(feature.directory.state.value?.sessions ?? [])
.filter((session) => session.sessionId === command.sessionId)
.map((summary) => ({ feature, summary })),
);
if (matches.length > 1) throw new Error(`Session ${command.sessionId} is available from more than one server`);
selected = matches[0];
if (selected === undefined) {
const feature = requireSingleServer(features);
selected = {
feature,
summary: await feature.management.create({ id: command.sessionId }, BACKGROUND_CONTEXT),
};
}
} else if (command.continue === true || command.resume === true) {
selected = features
.flatMap((feature) =>
(feature.directory.state.value?.sessions ?? []).map((summary) => ({ feature, summary })),
)
.sort(
(left, right) =>
right.summary.createdAt - left.summary.createdAt ||
left.summary.serverId.localeCompare(right.summary.serverId) ||
left.summary.sessionId.localeCompare(right.summary.sessionId),
)[0];
}
if (selected === undefined) {
const feature = requireSingleServer(features);
selected = { feature, summary: await feature.management.create({}, BACKGROUND_CONTEXT) };
}
await this.#attachSession(selected.feature, selected.summary);
}
async #attachSession(feature: SessionFeature, summary: SessionSummary): Promise<void> {
this.#status = `Attaching ${summary.sessionId}…`;
this.#rebuild();
await feature.management.attach(summary.sessionId, BACKGROUND_CONTEXT);
await feature.session.whenAttached(summary.sessionId, BACKGROUND_CONTEXT);
await this.#openLane(feature, summary.sessionId);
this.#selectedServerId = feature.serverId;
this.#screen = "chat";
this.#status = summary.sessionId;
this.#rebuild();
}
async #close(): Promise<void> {
const errors: unknown[] = [];
try {
await this.#closeLane();
} catch (error) {
errors.push(error);
}
const results = await Promise.allSettled(
this.#facetHosts
.splice(0)
.reverse()
.map((host) => host.dispose()),
);
const errors = results.flatMap((result) => (result.status === "rejected" ? [result.reason] : []));
errors.push(...results.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])));
try {
await this.#loadedFacets.dispose();
} catch (error) {
@ -245,89 +319,43 @@ export class ExperimentalClientTui implements Component {
}
#register<T extends { readonly serverId: string }>(features: Map<string, T>, label: string, feature: T): () => void {
if (features.has(feature.serverId)) {
if (features.has(feature.serverId))
throw new Error(`${label} is already registered for server ${feature.serverId}`);
}
features.set(feature.serverId, feature);
try {
this.#rebuild();
} catch (error) {
features.delete(feature.serverId);
throw error;
}
return () => {
if (features.get(feature.serverId) !== feature) return;
features.delete(feature.serverId);
this.#rebuild();
};
}
#rebuild(): void {
this.#container.clear();
this.#actions.clear();
const title =
this.#screen === "sessions"
? "Experimental Sessions"
: this.#screen === "models"
? "Experimental Models"
: "Experimental Chat";
this.#container.addChild(new Text(chalk.bold(title), 1, 1));
this.#container.addChild(new Text(this.#status, 1, 0));
if (this.#screen === "chat") {
this.#selectList = undefined;
this.#chatInput.focused = !this.#busy;
this.#container.addChild(this.#chatInput);
this.#container.addChild(new Text(chalk.dim(this.#busy ? "running turn…" : "enter send · esc models"), 1, 1));
} else {
this.#modelActions.clear();
if (this.#screen === "models") {
this.#chatInput.focused = false;
const items = this.#screen === "sessions" ? this.#sessionItems() : this.#modelItems();
this.#container.addChild(new Text(chalk.bold("Select model:"), 1, 1));
const items = this.#modelItems();
this.#selectList = new SelectList(items, Math.min(Math.max(items.length, 1), 12), selectTheme);
this.#selectList.onSelect = (item) => {
const action = this.#actions.get(item.value);
if (action !== undefined) void this.#runAction(action);
const action = this.#modelActions.get(item.value);
if (action !== undefined) void this.#selectModel(action);
};
this.#selectList.onCancel = () => {
if (this.#screen === "models") {
this.#screen = "sessions";
this.#status = "Select a Session or create one.";
this.#rebuild();
} else {
this.#finish();
}
this.#screen = "chat";
this.#rebuild();
};
this.#container.addChild(this.#selectList);
this.#container.addChild(
new Text(
chalk.dim(this.#screen === "sessions" ? "enter select · esc quit" : "enter select · esc sessions"),
1,
1,
),
);
this.#requestRender();
return;
}
this.#requestRender();
}
#sessionItems(): SelectItem[] {
const items: SelectItem[] = [];
for (const feature of this.#sessionPickers.values()) {
const createKey = `create:${feature.serverId}`;
this.#actions.set(createKey, { kind: "create", feature });
items.push({ value: createKey, label: "+ New Session", description: feature.serverId });
for (const session of feature.directory.state.value?.sessions ?? []) {
const key = `session:${feature.serverId}:${session.sessionId}`;
const attachment = feature.session.attachment.value;
const attached = attachment?.status === "attached" && attachment.sessionId === session.sessionId;
this.#actions.set(key, { kind: "attach", feature, sessionId: session.sessionId });
items.push({
value: key,
label: attached ? `* ${session.sessionId}` : session.sessionId,
description: attached ? `attached · ${feature.serverId}` : feature.serverId,
});
}
}
this.#actions.set("quit", { kind: "quit" });
items.push({ value: "quit", label: "Quit" });
return items;
this.#selectList = undefined;
this.#chatInput.focused = !this.#busy;
if (this.#status.length > 0) this.#container.addChild(new Text(theme.fg("dim", this.#status), 1, 0));
if (this.#chatView) this.#container.addChild(this.#chatView);
this.#container.addChild(this.#chatInput);
this.#container.addChild(new Text(theme.fg("dim", this.#footer()), 1, 1));
this.#requestRender();
}
#modelItems(): SelectItem[] {
@ -335,108 +363,159 @@ export class ExperimentalClientTui implements Component {
this.#selectedServerId === undefined ? undefined : this.#modelSelections.get(this.#selectedServerId);
const state = feature?.models.state.value;
const selected = state?.configuration.model;
const items: SelectItem[] = [];
for (const model of state?.catalog.availableModels ?? []) {
return (state?.catalog.availableModels ?? []).map((model) => {
const key = `model:${model.provider}:${model.modelId}`;
this.#actions.set(key, { kind: "model", provider: model.provider, modelId: model.modelId });
items.push({
this.#modelActions.set(key, { provider: model.provider, modelId: model.modelId });
return {
value: key,
label:
selected?.provider === model.provider && selected.modelId === model.modelId
? `${model.name} (selected)`
: model.name,
description: `${model.provider}/${model.modelId}`,
});
}
this.#actions.set("sessions", { kind: "sessions" });
items.push({ value: "sessions", label: "Back to Sessions" });
this.#actions.set("quit", { kind: "quit" });
items.push({ value: "quit", label: "Quit" });
return items;
};
});
}
async #runAction(action: ClientTuiAction): Promise<void> {
async #selectModel(action: ModelAction): Promise<void> {
if (this.#busy) return;
if (action.kind === "quit") {
this.#finish();
this.#busy = true;
try {
const feature =
this.#selectedServerId === undefined ? undefined : this.#modelSelections.get(this.#selectedServerId);
if (feature === undefined) throw new Error("No Session model selection is available");
await feature.models.select({ provider: action.provider, modelId: action.modelId }, BACKGROUND_CONTEXT);
this.#screen = "chat";
this.#status = `Selected ${action.provider}/${action.modelId}.`;
} catch (error) {
this.#status = `Error: ${message(error)}`;
} finally {
this.#busy = false;
this.#rebuild();
}
}
async #openLane(feature: SessionFeature, sessionId: string): Promise<void> {
await this.#closeLane();
const replica = await openLaneReplica(feature.laneWatches, sessionId);
const view = new ExperimentalChatView(this.#ui, process.cwd());
view.apply(replica.state());
this.#laneReplica = replica;
this.#chatView = view;
this.#laneUnsubscribe = replica.subscribe(() => {
view.apply(replica.state());
this.#rebuild();
});
}
async #closeLane(): Promise<void> {
this.#laneUnsubscribe?.();
this.#laneUnsubscribe = undefined;
this.#chatView?.dispose();
this.#chatView = undefined;
const replica = this.#laneReplica;
this.#laneReplica = undefined;
await replica?.close();
}
async #runPrompt(messageText: string): Promise<void> {
const prompt = messageText.trim();
if (prompt.length === 0) return;
if (prompt === "/model") {
this.#showModels();
return;
}
if (action.kind === "sessions") {
this.#screen = "sessions";
this.#status = "Select a Session or create one.";
const controller = this.#selectedController();
if (controller === undefined) {
this.#status = "No Session AgentController service is available.";
this.#rebuild();
return;
}
this.#busy = true;
this.#chatInput.setText("");
try {
if (action.kind === "model") {
const feature =
this.#selectedServerId === undefined ? undefined : this.#modelSelections.get(this.#selectedServerId);
if (feature === undefined) throw new Error("No Session model selection is available");
await feature.models.select({ provider: action.provider, modelId: action.modelId }, BACKGROUND_CONTEXT);
this.#screen = "chat";
this.#status = `Selected ${action.provider}/${action.modelId}. Enter a prompt.`;
if (prompt === "/compact" || prompt.startsWith("/compact ")) {
const instructions = prompt.slice("/compact".length).trim();
this.#status = "Compacting…";
this.#rebuild();
this.#reportOperation(
await controller.compact(
{ customInstructions: instructions.length === 0 ? null : instructions },
BACKGROUND_CONTEXT,
),
);
return;
}
const summary =
action.kind === "create"
? await action.feature.management.create({}, BACKGROUND_CONTEXT)
: findSession(action.feature, action.sessionId);
this.#status = `Attaching ${summary.sessionId}…`;
const operation = this.#laneReplica?.state().operation;
const running = operation !== null && operation !== undefined;
this.#status = running ? "Queueing steering message…" : "Running turn…";
this.#rebuild();
const selectedFeature =
this.#selectedServerId === undefined ? undefined : this.#sessionPickers.get(this.#selectedServerId);
if (selectedFeature !== undefined && selectedFeature !== action.feature) {
await selectedFeature.management.detach(BACKGROUND_CONTEXT);
await selectedFeature.session.whenDetached(BACKGROUND_CONTEXT);
}
await action.feature.management.attach(summary.sessionId, BACKGROUND_CONTEXT);
await action.feature.session.whenAttached(summary.sessionId, BACKGROUND_CONTEXT);
this.#selectedServerId = action.feature.serverId;
this.#screen = "models";
this.#status = `Attached ${summary.sessionId}. Select a model.`;
if (running) this.#reportQueue(await controller.steer({ message: prompt, images: null }, BACKGROUND_CONTEXT));
else this.#reportOperation(await controller.prompt({ message: prompt, images: null }, BACKGROUND_CONTEXT));
} catch (error) {
this.#status = `Error: ${error instanceof Error ? error.message : String(error)}`;
} finally {
this.#busy = false;
this.#status = `Error: ${message(error)}`;
this.#rebuild();
}
}
async #runPrompt(message: string): Promise<void> {
if (this.#busy) return;
const prompt = message.trim();
if (prompt.length === 0) {
this.#status = "Enter a prompt.";
this.#rebuild();
return;
}
this.#busy = true;
this.#chatInput.setValue("");
this.#status = "Running turn…";
this.#rebuild();
async #queueFollowUp(text: string): Promise<void> {
const controller = this.#selectedController();
if (controller === undefined) return;
try {
const feature = this.#selectedServerId === undefined ? undefined : this.#chats.get(this.#selectedServerId);
if (feature === undefined) throw new Error("No Session Chat service is available");
const response = await feature.chat.prompt({ message: prompt, images: null }, BACKGROUND_CONTEXT);
this.#status = response.accepted
? response.error === null
? `Turn ${response.operationId} completed.`
: `Turn failed: ${response.error.message}`
: `Prompt rejected: ${response.error.message}`;
this.#status = "Queueing follow-up…";
this.#rebuild();
this.#reportQueue(await controller.followUp({ message: text, images: null }, BACKGROUND_CONTEXT));
} catch (error) {
this.#status = `Error: ${error instanceof Error ? error.message : String(error)}`;
} finally {
this.#busy = false;
this.#status = `Error: ${message(error)}`;
this.#rebuild();
}
}
#reportOperation(response: AgentOperationResponse): void {
this.#status = response.accepted
? response.error === null
? `Operation ${response.operationId} completed.`
: `Operation failed: ${response.error.message}`
: `Operation rejected: ${response.error.message}`;
this.#rebuild();
}
#reportQueue(response: AgentQueueResponse): void {
this.#status = response.accepted ? `Queued ${response.entryId}.` : `Message rejected: ${response.error.message}`;
this.#rebuild();
}
#interrupt(): void {
const operation = this.#laneReplica?.state().operation;
const controller = this.#selectedController();
if (operation === null || operation === undefined || controller === undefined) return;
this.#status = `Aborting ${operation.id}…`;
this.#rebuild();
void controller.requestAbort(operation.id, BACKGROUND_CONTEXT).catch((error: unknown) => {
this.#status = `Error: ${message(error)}`;
this.#rebuild();
});
}
#showModels(): void {
this.#screen = "models";
this.#rebuild();
}
#selectedController(): AgentController | undefined {
return this.#selectedServerId === undefined ? undefined : this.#agents.get(this.#selectedServerId)?.controller;
}
#footer(): string {
const snapshot = this.#laneReplica?.state();
if (!snapshot) return "/model · /compact";
return `${snapshot.configuration.model.provider}/${snapshot.configuration.model.modelId} · thinking:${snapshot.configuration.thinkingLevel} · ${snapshot.stats.messageCount} messages · /model · /compact`;
}
}
export async function runClientTui(command: ClientCommand, options: RunClientTuiOptions = {}): Promise<void> {
const runtime = await openClientRuntime(command, options);
const terminal = new ProcessTerminal();
const tui = new TuiMainScreen(terminal);
const tui = new TuiMainScreen(terminal, undefined, getAgentDir());
let component: ExperimentalClientTui | undefined;
try {
let finish!: () => void;
@ -447,8 +526,11 @@ export async function runClientTui(command: ClientCommand, options: RunClientTui
};
});
component = await ExperimentalClientTui.create({
command,
ui: tui,
servers: runtime.servers.map((server) => ({
serverId: server.route.serverId,
laneWatches: server.client,
server: server.server,
session: server.session,
})),
@ -466,8 +548,11 @@ export async function runClientTui(command: ClientCommand, options: RunClientTui
}
}
function findSession(feature: SessionPickerFeature, sessionId: string): SessionSummary {
const session = feature.directory.state.value?.sessions.find((candidate) => candidate.sessionId === sessionId);
if (session === undefined) throw new Error(`Unknown Session: ${sessionId}`);
return session;
function requireSingleServer(features: readonly SessionFeature[]): SessionFeature {
if (features.length !== 1) throw new Error("Starting a Session requires exactly one server");
return features[0]!;
}
function message(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View file

@ -2,7 +2,7 @@ import { BACKGROUND_CONTEXT } from "@earendil-works/pi-agent-core";
import type { LaneEvent, PromptMessage, SessionAddress } from "@earendil-works/pi-protocol";
import type { ClientCommand } from "../cli/experimental/commands/client.ts";
import { activateBuiltinClientServices, openClientRuntime } from "./client-runtime.ts";
import type { ChatPromptResponse } from "./services/chat.ts";
import type { AgentOperationResponse } from "./services/agent-controller.ts";
export type ClientResult =
| {
@ -70,9 +70,9 @@ export async function runClient(command: ClientCommand, options: RunClientOption
return { kind: "attached", serverId: match.route.serverId, sessionId };
}
const chat = match.chat;
const agent = match.agent;
const completedText = new Map<string, string>();
// Chat deliberately returns no transcript content. Keep the compatibility watch until Transcript is implemented.
// AgentController returns no transcript content. Keep the lane watch until Transcript owns live deltas.
const watch = await match.client.watchSession(sessionId);
await watch.start(async (event) => {
if (event.type === "message_end" && event.runId !== undefined && event.message.role === "assistant") {
@ -80,9 +80,9 @@ export async function runClient(command: ClientCommand, options: RunClientOption
}
await options.onEvent?.(event);
});
let response: ChatPromptResponse;
let response: AgentOperationResponse;
try {
response = await chat.prompt({ message: command.prompt, images: null }, BACKGROUND_CONTEXT);
response = await agent.prompt({ message: command.prompt, images: null }, BACKGROUND_CONTEXT);
} finally {
await watch.dispose();
}

View file

@ -0,0 +1,62 @@
import { type HarnessEvent, type LaneSnapshot, reduceLaneSnapshot } from "@earendil-works/pi-agent-core";
import type { LaneWatch } from "@earendil-works/pi-client";
import type { LaneEvent as WireLaneEvent, LaneSnapshot as WireLaneSnapshot } from "@earendil-works/pi-protocol";
export interface LaneWatchSource {
watchSession(sessionId: string): Promise<LaneWatch>;
}
export interface LaneReplica {
readonly sessionId: string;
state(): LaneSnapshot;
subscribe(listener: () => void): () => void;
close(): Promise<void>;
}
/**
* Open the protocol's coherent snapshot/event watch as a presentation-local Harness replica.
*
* The protocol DTOs are the strict-JSON projection of these Harness types. Keeping the cast here
* lets every presentation use the Harness's normative reducer instead of maintaining a second fold.
*/
export async function openLaneReplica(source: LaneWatchSource, sessionId: string): Promise<LaneReplica> {
const watch = await source.watchSession(sessionId);
const listeners = new Set<() => void>();
let snapshot = fromWireSnapshot(watch.snapshot);
let closed = false;
const publish = (): void => {
if (closed) return;
for (const listener of listeners) listener();
};
await watch.start(async (wireEvent) => {
if (closed) return;
const folded = reduceLaneSnapshot(snapshot, fromWireEvent(wireEvent));
if ("rebase" in folded) snapshot = fromWireSnapshot(await watch.resnapshot());
else snapshot = folded;
publish();
});
return {
sessionId,
state: () => snapshot,
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
async close() {
if (closed) return;
closed = true;
listeners.clear();
await watch.dispose();
},
};
}
function fromWireSnapshot(snapshot: WireLaneSnapshot): LaneSnapshot {
return snapshot as unknown as LaneSnapshot;
}
function fromWireEvent(event: WireLaneEvent): HarnessEvent {
return event as unknown as HarnessEvent;
}

View file

@ -6,15 +6,15 @@ Facet setup generates each host's RPC service catalogue from its provided RPC-ca
|---|---|---|---|
| server | `SessionDirectory` | replicated state implemented | add authenticated per-client projection when identity lands |
| server | `SessionManagement` | create, remove, attach, detach implemented | add authenticated workspace authorization |
| session | `Models` | state, selection, thinking, refresh implemented | move provider/auth composition behind plugin facets |
| session | `Chat` | prompt and durable `requestAbort` route to `AgentLane`; queue, resume, compaction, and navigation contracts declared | implement the corresponding `AgentLane` execution slices |
| session | `Models` | state, default-persisting selection, thinking, refresh implemented | move provider/auth composition behind plugin facets |
| session | `AgentController` | presentation-safe `AgentLane` facade for prompting, queueing, abort, resume, compaction, and navigation | add new lane operations only when a presentation requires them |
| session | `Accounts` | state declared; mutations throw | connect the local credential service to the presentation-safe facade |
| session | `Transcript` | authoritative snapshot declared but throws | implement snapshot projection and remove the compatibility lane watch |
`ServerServiceConnection.connection` and `SessionServiceConnection.attachment` are implemented local control states.
With `PI_EXPERIMENTAL=1`, an interactive `pi client` opens the service-only TUI. It lists the repository-backed `SessionDirectory`, creates or switches Sessions through `SessionManagement`, selects from the attached worker's `Models` state, and submits single-line prompts through `Chat`. Completion and errors are shown as status text; successful turns remain blocked on the `AgentLane.prompt` execution slice, and rendering the conversation waits for the `Transcript` slice.
With `PI_EXPERIMENTAL=1`, an interactive `pi client` creates and attaches a Session before opening the service-only chat TUI. `pi client -c` and `pi client -r` attach the newest existing Session instead, preserving its durable model and thinking configuration. Model selection is available on demand through `/model`; it is never a startup screen. The presentation drives the worker-owned main lane through `AgentController` and renders a coherent lane-watch replica with the Harness reducer while controller calls are pending. `Transcript` remains reserved for a later service-owned live-delta replacement of that compatibility watch.
The Session worker loads all built-in and configured Session facets through one `FacetLoader`, then creates one active `FacetHost`. The service-only TUI likewise loads all TUI facets once and creates one host that combines its local `Tui` service with connected server and selected-Session services. A host may reload selected facets when their declared service shape is unchanged; retained consumers keep the same local and RPC service facades while replacement implementations and singleton snapshots are installed behind them. Runtime facets provide host-created services such as `Harness`, `Lane`, `ModelsRuntime`, and `Tui` through the same `env.provide()` operation used by every other facet. Synchronous setup-time `env.provide()`, `env.provideMany()`, `env.use()`, and `env.observe()` calls produce the internal dependency graph; setup does not repeat a declarative dependency list, and service handles remain disconnected until the complete graph validates. Providers activate before consumers, observations connect with their consuming facet, and facet replacement or host shutdown disposes affected lifecycles in reverse dependency order. Services are RPC-capable by default; process-local tokens such as `Harness`, `Lane`, `ModelsRuntime`, and `Tui` declare `{ rpc: false }`. The host automatically publishes every provided RPC-capable service.
The Session worker loads all built-in and configured Session facets through one `FacetLoader`, then creates one active `FacetHost`. The service-only TUI likewise loads all TUI facets once and creates one host that combines its local `Tui` service with connected server and selected-Session services. A host may reload selected facets when their declared service shape is unchanged; retained consumers keep the same local and RPC service facades while replacement implementations and singleton snapshots are installed behind them. Runtime facets provide host-created services such as `AgentController`, `ModelsRuntime`, and `Tui` through the same `env.provide()` operation used by every other facet. Synchronous setup-time `env.provide()`, `env.provideMany()`, `env.use()`, and `env.observe()` calls produce the internal dependency graph; setup does not repeat a declarative dependency list, and service handles remain disconnected until the complete graph validates. Providers activate before consumers, observations connect with their consuming facet, and facet replacement or host shutdown disposes affected lifecycles in reverse dependency order. Services are RPC-capable by default; process-local tokens such as `ModelsRuntime` and `Tui` declare `{ rpc: false }`. The host automatically publishes every provided RPC-capable service.
A facet always calls unqualified `env.use()` or `env.observe()`. The host resolves each token across facet-provided and connected services. Transport namespaces remain internal binding machinery rather than part of the facet environment. TUI facets own subscriptions to their replicated state; `ExperimentalClientTui` retains only terminal rendering, navigation, and action dispatch. The server provider remains directly assembled until its complete host environment exists. The question dialog, diff review, Git, indexing-job, and canvas examples in `packages/agent/docs/plugins.md` are extension patterns, not built-in coding-agent services. Private references, trace carriers, and flow control remain protocol/host infrastructure slices rather than presentation service tokens.

View file

@ -0,0 +1,117 @@
import type { AgentLane, OperationResultRecord, SuspendedRun } from "@earendil-works/pi-agent-core";
import type { ImageContent } from "@earendil-works/pi-ai";
import type { PromptArguments } from "@earendil-works/pi-protocol";
import { toHarnessPromptArguments } from "../harness-wire-adapter.ts";
import type {
AgentController as AgentControllerService,
AgentOperationError,
AgentOperationResponse,
AgentPromptRequest,
AgentQueueResponse,
} from "./agent-controller.ts";
export function createAgentController(lane: AgentLane): AgentControllerService {
const queue = async (
operation: "steer" | "followUp" | "nextRun",
request: AgentPromptRequest,
context: Parameters<AgentControllerService["steer"]>[1],
): Promise<AgentQueueResponse> => {
const [message, images] = toTextPrompt(request);
const result = await lane[operation](message, images, context);
return result.ok
? { accepted: true, entryId: result.value.entryId, error: null }
: { accepted: false, entryId: null, error: toAgentError(result.error) };
};
return {
async prompt(request, context) {
const [message, images] = toTextPrompt(request);
const result = await lane.prompt(message, images, context);
return result.ok
? toOperationResponse(result.value)
: { accepted: false, operationId: operationId(result.error), error: toAgentError(result.error) };
},
async requestAbort(operationId, context) {
const result = await lane.requestAbort(operationId, context);
if (!result.ok) throw new Error(result.error.message);
},
steer: (request, context) => queue("steer", request, context),
followUp: (request, context) => queue("followUp", request, context),
nextRun: (request, context) => queue("nextRun", request, context),
async cancelQueued(entryId, context) {
const result = await lane.cancelQueued(entryId, context);
if (!result.ok) throw new Error(result.error.message);
return { outcome: result.value.kind };
},
async resume(context) {
const result = await lane.resume(context);
return result.ok
? toOperationResponse(result.value)
: { accepted: false, operationId: null, error: toAgentError(result.error) };
},
async compact(request, context) {
const result = await lane.compact(
request.customInstructions === null ? undefined : { customInstructions: request.customInstructions },
context,
);
return result.ok
? toOperationResponse(result.value.compaction)
: { accepted: false, operationId: operationId(result.error), error: toAgentError(result.error) };
},
async navigate(request, context) {
const result = await lane.navigateTree(
request.targetId,
{
summarize: request.summarize,
...(request.label === null ? {} : { label: request.label }),
...(request.customInstructions === null ? {} : { customInstructions: request.customInstructions }),
},
context,
);
return result.ok
? toOperationResponse(result.value.navigation)
: { accepted: false, operationId: operationId(result.error), error: toAgentError(result.error) };
},
};
}
function toOperationResponse(value: OperationResultRecord | SuspendedRun): AgentOperationResponse {
return {
accepted: true,
operationId: value.operationId,
error:
"status" in value && value.status === "failed" && value.error !== undefined
? { code: value.error.code, message: value.error.message }
: null,
};
}
function operationId(error: { readonly _tag: string }): string | null {
return "operationId" in error && typeof error.operationId === "string" ? error.operationId : null;
}
function toAgentError(error: { readonly _tag: string; readonly message: string }): AgentOperationError {
const code =
{
LaneBusy: "lane_busy",
InvalidMessage: "invalid_message",
UnknownSkill: "unknown_skill",
UnknownTemplate: "unknown_template",
NothingToCompact: "nothing_to_compact",
NothingToResume: "nothing_to_resume",
InvalidNavigation: "invalid_navigation",
UnknownTarget: "unknown_target",
Closed: "closed",
}[error._tag] ?? "operation_failed";
return { code, message: error.message };
}
function toTextPrompt(request: AgentPromptRequest): [message: string, images: ImageContent[] | undefined] {
const prompt = toHarnessPromptArguments(toPromptArguments(request));
if (typeof prompt[0] !== "string") throw new TypeError("AgentController prompt did not contain text");
return [prompt[0], prompt.length === 1 ? undefined : prompt[1]];
}
function toPromptArguments(request: AgentPromptRequest): PromptArguments {
return request.images === null ? [request.message] : [request.message, request.images];
}

View file

@ -0,0 +1,49 @@
import { type Context, defineService } from "@earendil-works/pi-agent-core";
import type { PromptImage } from "@earendil-works/pi-protocol";
export interface AgentPromptRequest {
message: string;
images: PromptImage[] | null;
}
export interface AgentOperationError {
code: string;
message: string;
}
export type AgentOperationResponse =
| { accepted: true; operationId: string; error: AgentOperationError | null }
| { accepted: false; operationId: string | null; error: AgentOperationError };
export type AgentQueueResponse =
| { accepted: true; entryId: string; error: null }
| { accepted: false; entryId: null; error: AgentOperationError };
export interface AgentCompactionRequest {
customInstructions: string | null;
}
export interface AgentNavigationRequest {
targetId: string | null;
summarize: boolean;
label: string | null;
customInstructions: string | null;
}
/** Presentation-safe command facade over the worker-owned main AgentLane. */
export interface AgentController {
prompt(request: AgentPromptRequest, context: Context): Promise<AgentOperationResponse>;
requestAbort(operationId: string, context: Context): Promise<void>;
steer(request: AgentPromptRequest, context: Context): Promise<AgentQueueResponse>;
followUp(request: AgentPromptRequest, context: Context): Promise<AgentQueueResponse>;
nextRun(request: AgentPromptRequest, context: Context): Promise<AgentQueueResponse>;
cancelQueued(
entryId: string,
context: Context,
): Promise<{ outcome: "cancelled" | "already_consumed" | "not_found" }>;
resume(context: Context): Promise<AgentOperationResponse>;
compact(request: AgentCompactionRequest, context: Context): Promise<AgentOperationResponse>;
navigate(request: AgentNavigationRequest, context: Context): Promise<AgentOperationResponse>;
}
export const AgentController = defineService<AgentController>("pi.agent-controller");

View file

@ -1,101 +0,0 @@
import {
type AgentLane,
type RunResult as HarnessRunResult,
ServiceSliceNotImplemented,
SliceNotImplemented,
} from "@earendil-works/pi-agent-core";
import type { PromptArguments } from "@earendil-works/pi-protocol";
import { defineFacet } from "../facets.ts";
import { toHarnessPromptArguments } from "../harness-wire-adapter.ts";
import { Chat, type ChatPromptRequest, type ChatPromptResponse, type Chat as ChatService } from "./chat.ts";
import { Lane } from "./harness.ts";
export function createChatService(lane: AgentLane): ChatService {
return {
async prompt(request, context) {
return mapHarnessSlice("Chat.prompt", async () => {
const prompt = toHarnessPromptArguments(toPromptArguments(request));
const result =
typeof prompt[0] === "string"
? await lane.prompt(prompt[0], prompt[1], context)
: await lane.prompt(prompt[0], context);
return toChatPromptResponse(result);
});
},
async requestAbort(operationId, context) {
await mapHarnessSlice("Chat.requestAbort", async () => {
const result = await lane.requestAbort(operationId, context);
if (!result.ok) throw new Error(result.error.message);
});
},
async steer() {
throw new ServiceSliceNotImplemented("Chat.steer");
},
async followUp() {
throw new ServiceSliceNotImplemented("Chat.followUp");
},
async nextRun() {
throw new ServiceSliceNotImplemented("Chat.nextRun");
},
async cancelQueued() {
throw new ServiceSliceNotImplemented("Chat.cancelQueued");
},
async resume() {
throw new ServiceSliceNotImplemented("Chat.resume");
},
async compact() {
throw new ServiceSliceNotImplemented("Chat.compact");
},
async navigate() {
throw new ServiceSliceNotImplemented("Chat.navigate");
},
};
}
async function mapHarnessSlice<T>(operation: string, callback: () => Promise<T>): Promise<T> {
try {
return await callback();
} catch (error) {
if (error instanceof SliceNotImplemented) throw new ServiceSliceNotImplemented(operation);
throw error;
}
}
export const chatServiceFacet = defineFacet({
id: "@pi/chat",
setup(env) {
env.provide(Chat, createChatService(env.use(Lane)));
},
});
export function toChatPromptResponse(result: HarnessRunResult): ChatPromptResponse {
if (result.ok) {
return {
accepted: true,
operationId: result.value.operationId,
error:
result.value.status === "failed" && result.value.error !== undefined
? {
code: result.value.error.code,
message: result.value.error.message,
}
: null,
};
}
const code = {
LaneBusy: "lane_busy",
InvalidMessage: "invalid_message",
UnknownSkill: "unknown_skill",
UnknownTemplate: "unknown_template",
Closed: "closed",
}[result.error._tag];
return {
accepted: false,
operationId: result.error._tag === "LaneBusy" ? result.error.operationId : null,
error: { code, message: result.error.message },
};
}
function toPromptArguments(request: ChatPromptRequest): PromptArguments {
return request.images === null ? [request.message] : [request.message, request.images];
}

View file

@ -1,48 +0,0 @@
import { type Context, defineService } from "@earendil-works/pi-agent-core";
import type { PromptImage } from "@earendil-works/pi-protocol";
export interface ChatPromptRequest {
message: string;
images: PromptImage[] | null;
}
export interface ChatPromptError {
code: string;
message: string;
}
export type ChatPromptResponse =
| { accepted: true; operationId: string; error: ChatPromptError | null }
| { accepted: false; operationId: string | null; error: ChatPromptError };
export type ChatQueueResponse =
| { accepted: true; entryId: string; error: null }
| { accepted: false; entryId: null; error: ChatPromptError };
export interface ChatCompactionRequest {
customInstructions: string | null;
}
export interface ChatNavigationRequest {
targetId: string | null;
summarize: boolean;
label: string | null;
customInstructions: string | null;
}
export interface Chat {
prompt(request: ChatPromptRequest, context: Context): Promise<ChatPromptResponse>;
requestAbort(operationId: string, context: Context): Promise<void>;
steer(request: ChatPromptRequest, context: Context): Promise<ChatQueueResponse>;
followUp(request: ChatPromptRequest, context: Context): Promise<ChatQueueResponse>;
nextRun(request: ChatPromptRequest, context: Context): Promise<ChatQueueResponse>;
cancelQueued(
entryId: string,
context: Context,
): Promise<{ outcome: "cancelled" | "already_consumed" | "not_found" }>;
resume(context: Context): Promise<ChatPromptResponse>;
compact(request: ChatCompactionRequest, context: Context): Promise<ChatPromptResponse>;
navigate(request: ChatNavigationRequest, context: Context): Promise<ChatPromptResponse>;
}
export const Chat = defineService<Chat>("pi.chat");

View file

@ -1,4 +0,0 @@
import { type AgentHarness, type AgentLane, defineService } from "@earendil-works/pi-agent-core";
export const Harness = defineService<AgentHarness>("pi.local.harness", { rpc: false });
export const Lane = defineService<AgentLane>("pi.local.lane", { rpc: false });

View file

@ -7,25 +7,31 @@ import {
} from "@earendil-works/pi-agent-core";
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
import type { ModelRuntime } from "../../core/model-runtime.ts";
import { defineFacet } from "../facets.ts";
import { Lane } from "./harness.ts";
import type { SettingsManager } from "../../core/settings-manager.ts";
import { defineFacet, type Facet } from "../facets.ts";
import { Models, type Models as ModelsService, type ModelsState } from "./models.ts";
export interface ModelsRuntime {
getAvailableSnapshot(): ReturnType<ModelRuntime["getAvailableSnapshot"]>;
refresh(options?: Parameters<ModelRuntime["refresh"]>[0]): ReturnType<ModelRuntime["refresh"]> | undefined;
getModel(...args: Parameters<ModelRuntime["getModel"]>): ReturnType<ModelRuntime["getModel"]>;
persistModel(provider: string, modelId: string): Promise<void>;
}
export const ModelsRuntime = defineService<ModelsRuntime>("pi.local.models-runtime", { rpc: false });
export function createModelsRuntime(runtime: ModelRuntime | undefined): ModelsRuntime {
return (
runtime ?? {
getAvailableSnapshot: () => [],
refresh: () => undefined,
getModel: () => undefined,
}
);
export function createModelsRuntime(
runtime: ModelRuntime | undefined,
settingsManager?: SettingsManager,
): ModelsRuntime {
return {
getAvailableSnapshot: () => runtime?.getAvailableSnapshot() ?? [],
refresh: (options) => runtime?.refresh(options),
getModel: (...args) => runtime?.getModel(...args),
async persistModel(provider, modelId) {
settingsManager?.setDefaultModelAndProvider(provider, modelId);
await settingsManager?.flush();
},
};
}
export interface ModelsServiceRuntime {
@ -110,6 +116,7 @@ export function createModelsService(
const selected = modelRuntime.getModel(model.provider, model.modelId);
if (selected === undefined) throw new Error(`Unknown model: ${model.provider}/${model.modelId}`);
await lane.setModel({ provider: selected.provider, modelId: selected.id }, context);
await modelRuntime.persistModel(selected.provider, selected.id);
state.set({ ...state.value, configuration: await readConfiguration(context) }, context);
},
};
@ -122,14 +129,16 @@ export function createModelsService(
};
}
export const modelsServiceFacet = defineFacet({
id: "@pi/models",
setup(env) {
const runtime = createModelsService(env.use(Lane), env.use(ModelsRuntime), env.replicatedState);
env.provide(Models, runtime.service);
env.onActivate(() => runtime.activate(BACKGROUND_CONTEXT));
},
});
export function createModelsServiceFacet(lane: AgentLane): Facet {
return defineFacet({
id: "@pi/models",
setup(env) {
const runtime = createModelsService(lane, env.use(ModelsRuntime), env.replicatedState);
env.provide(Models, runtime.service);
env.onActivate(() => runtime.activate(BACKGROUND_CONTEXT));
},
});
}
function includesModel(
models: readonly { readonly provider: string; readonly id: string }[],

View file

@ -1,12 +1,12 @@
import { defineService } from "@earendil-works/pi-agent-core";
import type { Chat } from "./chat.ts";
import type { AgentController } from "./agent-controller.ts";
import type { Models } from "./models.ts";
import type { SessionDirectory, SessionManagement } from "./sessions.ts";
export interface Tui {
registerSessionPicker(directory: SessionDirectory, management: SessionManagement): () => void;
registerSessions(directory: SessionDirectory, management: SessionManagement): () => void;
registerModelSelection(models: Models): () => void;
registerChat(chat: Chat): () => void;
registerAgentController(controller: AgentController): () => void;
refresh(): void;
setStatus(status: string): void;
}

View file

@ -17,11 +17,12 @@ import {
import Type, { type Static } from "typebox";
import { Check } from "typebox/value";
import type { ModelRuntime } from "../../core/model-runtime.ts";
import type { SettingsManager } from "../../core/settings-manager.ts";
import { combineFacetLoaders, createStaticFacetLoader, type FacetLoader } from "../facet-loader.ts";
import { createFacetHost, defineFacet, type Facet, type FacetHost } from "../facets.ts";
import { chatServiceFacet } from "./chat-provider.ts";
import { Harness, Lane } from "./harness.ts";
import { createModelsRuntime, ModelsRuntime, modelsServiceFacet } from "./models-provider.ts";
import { AgentController } from "./agent-controller.ts";
import { createAgentController } from "./agent-controller-provider.ts";
import { createModelsRuntime, createModelsServiceFacet, ModelsRuntime } from "./models-provider.ts";
import { accountsServiceFacet, transcriptServiceFacet } from "./stubs-provider.ts";
export const ServiceOperationResultSchema = Type.Object(
@ -34,6 +35,7 @@ export interface SessionWorkerRuntime {
readonly harness: AgentHarness;
readonly lane?: AgentLane;
readonly modelRuntime?: ModelRuntime;
readonly settingsManager?: SettingsManager;
readonly facetLoader?: FacetLoader;
}
@ -54,31 +56,31 @@ export interface SessionWorkerServices {
dispose(): Promise<void>;
}
const BUILTIN_SESSION_FACET_LOADER = createStaticFacetLoader([
chatServiceFacet,
modelsServiceFacet,
accountsServiceFacet,
transcriptServiceFacet,
] satisfies readonly Facet[]);
function createBuiltinSessionFacetLoader(lane: AgentLane): FacetLoader {
return createStaticFacetLoader([
createModelsServiceFacet(lane),
accountsServiceFacet,
transcriptServiceFacet,
] satisfies readonly Facet[]);
}
export async function createSessionWorkerServices(options: {
readonly harness: AgentHarness;
readonly lane: AgentLane;
readonly modelRuntime: ModelRuntime | undefined;
readonly settingsManager?: SettingsManager;
readonly facetLoader?: FacetLoader;
publish(scope: WorkerServiceScope, subscriptionId: string, update: ProtocolServiceProviderUpdate): Promise<void>;
}): Promise<SessionWorkerServices> {
const sessionRuntimeFacet = defineFacet({
id: "@pi/session-runtime",
setup(env) {
env.provide(Harness, options.harness);
env.provide(Lane, options.lane);
env.provide(ModelsRuntime, createModelsRuntime(options.modelRuntime));
env.provide(AgentController, createAgentController(options.lane));
env.provide(ModelsRuntime, createModelsRuntime(options.modelRuntime, options.settingsManager));
},
});
const loader = combineFacetLoaders([
createStaticFacetLoader([sessionRuntimeFacet]),
BUILTIN_SESSION_FACET_LOADER,
createBuiltinSessionFacetLoader(options.lane),
...(options.facetLoader === undefined ? [] : [options.facetLoader]),
]);
const loaded = await loader.load();

View file

@ -587,9 +587,9 @@ async function run(options: SessionWorkerOptions, createHarness: CreateSessionWo
harness = runtime.harness;
lane = runtime.lane ?? (await harness.lane("main", TODO_CONTEXT));
services = await createSessionWorkerServices({
harness,
lane,
modelRuntime: runtime.modelRuntime,
settingsManager: runtime.settingsManager,
facetLoader: runtime.facetLoader,
publish: (scope, subscriptionId, update) =>
control.send({
@ -890,9 +890,9 @@ async function createCodingAgentHarness(
executionEnv: NodeExecutionEnv,
): Promise<SessionWorkerRuntime> {
const modelRuntime = await ModelRuntime.create();
const settingsManager = SettingsManager.create(session.metadata.cwd);
let resolved: Awaited<ReturnType<typeof findInitialModel>> | ReturnType<typeof resolveCliModel>;
if (options.model === undefined) {
const settingsManager = SettingsManager.create(session.metadata.cwd);
resolved = await findInitialModel({
scopedModels: [],
isContinuing: true,
@ -936,23 +936,7 @@ async function createCodingAgentHarness(
) {
await lane.setActiveTools(activeToolNames, TODO_CONTEXT);
}
if (options.model !== undefined) {
const currentModel = await lane.getModel(TODO_CONTEXT);
if (
!currentModel ||
currentModel.provider !== resolved.model.provider ||
currentModel.id !== resolved.model.id
) {
await lane.setModel({ provider: resolved.model.provider, modelId: resolved.model.id }, TODO_CONTEXT);
}
if (
resolved.thinkingLevel !== undefined &&
(await lane.getThinkingLevel(TODO_CONTEXT)) !== resolved.thinkingLevel
) {
await lane.setThinkingLevel(resolved.thinkingLevel, TODO_CONTEXT);
}
}
return { harness, lane, modelRuntime };
return { harness, lane, modelRuntime, settingsManager };
} catch (error) {
try {
await harness.close(TODO_CONTEXT);

View file

@ -0,0 +1,178 @@
import {
type AgentLane,
BACKGROUND_CONTEXT,
Closed,
type RunResult as HarnessRunResult,
InvalidMessage,
LaneBusy,
UnknownSkill,
UnknownTemplate,
} from "@earendil-works/pi-agent-core";
import { describe, expect, test, vi } from "vitest";
import { createFacetHost, defineFacet } from "../src/experimental/facets.ts";
import { AgentController } from "../src/experimental/services/agent-controller.ts";
import { createAgentController } from "../src/experimental/services/agent-controller-provider.ts";
const admissionErrors = [
[
new LaneBusy({
lane: "main",
operationId: "operation-1",
operationKind: "run",
message: "busy",
}),
"lane_busy",
"operation-1",
],
[new InvalidMessage({ lane: "main", reason: "invalid", message: "invalid" }), "invalid_message", null],
[new UnknownSkill({ name: "skill", message: "unknown" }), "unknown_skill", null],
[new UnknownTemplate({ name: "template", message: "unknown" }), "unknown_template", null],
[new Closed({ message: "closed" }), "closed", null],
] as const satisfies readonly [Extract<HarnessRunResult, { ok: false }>["error"], string, string | null][];
const completed = {
operationId: "operation-1",
kind: "run" as const,
status: "completed" as const,
fromTipId: null,
tipId: "entry-1",
startedAt: 1,
endedAt: 2,
};
describe("AgentController service", () => {
test("provides the presentation-safe AgentLane command surface", async () => {
const prompt = vi.fn(async () => ({ ok: true as const, value: completed }));
const requestAbort = vi.fn(async () => ({
ok: true as const,
value: { operationId: "operation-1", newlyRequested: true, steer: [], followUp: [] },
}));
const steer = vi.fn(async () => ({ ok: true as const, value: { entryId: "queue-1" } }));
const followUp = vi.fn(async () => ({ ok: true as const, value: { entryId: "queue-2" } }));
const lane = { prompt, requestAbort, steer, followUp } as unknown as AgentLane;
const controllerFacet = defineFacet({
id: "test-agent-controller",
setup(env) {
env.provide(AgentController, createAgentController(lane));
},
});
const host = await createFacetHost({ facets: [controllerFacet] });
try {
expect(host.services.catalogue).toEqual([{ serviceId: AgentController.id, mode: "singleton" }]);
await expect(
host.services.invoke(
{ serviceId: AgentController.id, member: "prompt", args: [{ message: "hello", images: null }] },
BACKGROUND_CONTEXT,
),
).resolves.toEqual({ accepted: true, operationId: "operation-1", error: null });
await expect(
host.services.invoke(
{ serviceId: AgentController.id, member: "requestAbort", args: ["operation-1"] },
BACKGROUND_CONTEXT,
),
).resolves.toBeUndefined();
await expect(
host.services.invoke(
{ serviceId: AgentController.id, member: "steer", args: [{ message: "later", images: null }] },
BACKGROUND_CONTEXT,
),
).resolves.toEqual({ accepted: true, entryId: "queue-1", error: null });
await expect(
host.services.invoke(
{ serviceId: AgentController.id, member: "followUp", args: [{ message: "after", images: null }] },
BACKGROUND_CONTEXT,
),
).resolves.toEqual({ accepted: true, entryId: "queue-2", error: null });
expect(prompt).toHaveBeenCalledWith("hello", undefined, BACKGROUND_CONTEXT);
expect(requestAbort).toHaveBeenCalledWith("operation-1", BACKGROUND_CONTEXT);
expect(steer).toHaveBeenCalledWith("later", undefined, BACKGROUND_CONTEXT);
expect(followUp).toHaveBeenCalledWith("after", undefined, BACKGROUND_CONTEXT);
} finally {
await host.dispose();
}
});
test("wraps queue, resume, compaction, and navigation lane operations", async () => {
const nextRun = vi.fn(async () => ({ ok: true as const, value: { entryId: "queue-3" } }));
const cancelQueued = vi.fn(async () => ({ ok: true as const, value: { kind: "cancelled" as const } }));
const resume = vi.fn(async () => ({ ok: true as const, value: completed }));
const compact = vi.fn(async () => ({
ok: true as const,
value: { compaction: { ...completed, operationId: "compact-1", kind: "compaction" as const } },
}));
const navigateTree = vi.fn(async () => ({
ok: true as const,
value: { navigation: { ...completed, operationId: "navigation-1", kind: "navigation" as const } },
}));
const controller = createAgentController({
nextRun,
cancelQueued,
resume,
compact,
navigateTree,
} as unknown as AgentLane);
await expect(controller.nextRun({ message: "next", images: null }, BACKGROUND_CONTEXT)).resolves.toEqual({
accepted: true,
entryId: "queue-3",
error: null,
});
await expect(controller.cancelQueued("queue-3", BACKGROUND_CONTEXT)).resolves.toEqual({
outcome: "cancelled",
});
await expect(controller.resume(BACKGROUND_CONTEXT)).resolves.toMatchObject({ operationId: "operation-1" });
await expect(controller.compact({ customInstructions: "short" }, BACKGROUND_CONTEXT)).resolves.toMatchObject({
operationId: "compact-1",
});
await expect(
controller.navigate(
{ targetId: "entry-1", summarize: true, label: "branch", customInstructions: null },
BACKGROUND_CONTEXT,
),
).resolves.toMatchObject({ operationId: "navigation-1" });
expect(nextRun).toHaveBeenCalledWith("next", undefined, BACKGROUND_CONTEXT);
expect(cancelQueued).toHaveBeenCalledWith("queue-3", BACKGROUND_CONTEXT);
expect(resume).toHaveBeenCalledWith(BACKGROUND_CONTEXT);
expect(compact).toHaveBeenCalledWith({ customInstructions: "short" }, BACKGROUND_CONTEXT);
expect(navigateTree).toHaveBeenCalledWith("entry-1", { summarize: true, label: "branch" }, BACKGROUND_CONTEXT);
});
test("reports accepted successful and failed operations", async () => {
const success = createAgentController({
prompt: async () => ({ ok: true, value: completed }),
} as unknown as AgentLane);
await expect(success.prompt({ message: "hello", images: null }, BACKGROUND_CONTEXT)).resolves.toEqual({
accepted: true,
operationId: "operation-1",
error: null,
});
const failed = createAgentController({
prompt: async () => ({
ok: true,
value: {
...completed,
operationId: "operation-2",
status: "failed",
error: { code: "provider", message: "failed", details: { status: 500 } },
},
}),
} as unknown as AgentLane);
await expect(failed.prompt({ message: "hello", images: null }, BACKGROUND_CONTEXT)).resolves.toEqual({
accepted: true,
operationId: "operation-2",
error: { code: "provider", message: "failed" },
});
});
test.each(admissionErrors)("maps admission error %# to a stable response", async (error, code, operationId) => {
const controller = createAgentController({
prompt: async () => ({ ok: false, error }),
} as unknown as AgentLane);
await expect(controller.prompt({ message: "hello", images: null }, BACKGROUND_CONTEXT)).resolves.toEqual({
accepted: false,
operationId,
error: { code, message: error.message },
});
});
});

View file

@ -1,160 +0,0 @@
import {
type AgentLane,
BACKGROUND_CONTEXT,
Closed,
type RunResult as HarnessRunResult,
InvalidMessage,
LaneBusy,
ServiceSliceNotImplemented,
SliceNotImplemented,
UnknownSkill,
UnknownTemplate,
} from "@earendil-works/pi-agent-core";
import { describe, expect, test, vi } from "vitest";
import { createFacetHost, defineFacet } from "../src/experimental/facets.ts";
import {
chatServiceFacet,
createChatService,
toChatPromptResponse,
} from "../src/experimental/services/chat-provider.ts";
import { Lane } from "../src/experimental/services/harness.ts";
const admissionErrors = [
[
new LaneBusy({
lane: "main",
operationId: "operation-1",
operationKind: "run",
message: "busy",
}),
"lane_busy",
"operation-1",
],
[new InvalidMessage({ lane: "main", reason: "invalid", message: "invalid" }), "invalid_message", null],
[new UnknownSkill({ name: "skill", message: "unknown" }), "unknown_skill", null],
[new UnknownTemplate({ name: "template", message: "unknown" }), "unknown_template", null],
[new Closed({ message: "closed" }), "closed", null],
] as const satisfies readonly [Extract<HarnessRunResult, { ok: false }>["error"], string, string | null][];
describe("Chat service", () => {
test("provides prompt and durable abort methods", async () => {
const prompt = vi.fn(async () => ({
ok: true as const,
value: {
operationId: "operation-1",
kind: "run" as const,
status: "completed" as const,
fromTipId: null,
tipId: "entry-1",
startedAt: 1,
endedAt: 2,
},
}));
const requestAbort = vi.fn(async () => ({
ok: true as const,
value: {
operationId: "operation-1",
newlyRequested: true,
steer: [],
followUp: [],
},
}));
const lane = { prompt, requestAbort } as unknown as AgentLane;
const laneFacet = defineFacet({
id: "test-lane",
setup(env) {
env.provide(Lane, lane);
},
});
const host = await createFacetHost({ facets: [laneFacet, chatServiceFacet] });
try {
await expect(
host.services.invoke(
{ serviceId: "pi.chat", member: "prompt", args: [{ message: "hello", images: null }] },
BACKGROUND_CONTEXT,
),
).resolves.toEqual({ accepted: true, operationId: "operation-1", error: null });
await expect(
host.services.invoke(
{ serviceId: "pi.chat", member: "requestAbort", args: ["operation-1"] },
BACKGROUND_CONTEXT,
),
).resolves.toBeUndefined();
expect(prompt).toHaveBeenCalledWith("hello", undefined, BACKGROUND_CONTEXT);
expect(requestAbort).toHaveBeenCalledWith("operation-1", BACKGROUND_CONTEXT);
await expect(
host.services.invoke(
{ serviceId: "pi.chat", member: "steer", args: [{ message: "later", images: null }] },
BACKGROUND_CONTEXT,
),
).rejects.toBeInstanceOf(ServiceSliceNotImplemented);
} finally {
await host.dispose();
}
});
test("maps missing Harness slices to service errors", async () => {
const lane = {
async prompt() {
throw new SliceNotImplemented("prompt");
},
async requestAbort() {
throw new SliceNotImplemented("requestAbort");
},
} as unknown as AgentLane;
const chat = createChatService(lane);
await expect(chat.prompt({ message: "hello", images: null }, BACKGROUND_CONTEXT)).rejects.toMatchObject({
name: "ServiceSliceNotImplemented",
code: "service_not_implemented",
});
await expect(chat.requestAbort("operation-1", BACKGROUND_CONTEXT)).rejects.toMatchObject({
name: "ServiceSliceNotImplemented",
code: "service_not_implemented",
});
});
test("reports accepted successful and failed operations", () => {
expect(
toChatPromptResponse({
ok: true,
value: {
operationId: "operation-1",
kind: "run",
status: "completed",
fromTipId: null,
tipId: "entry-1",
startedAt: 1,
endedAt: 2,
},
}),
).toEqual({ accepted: true, operationId: "operation-1", error: null });
expect(
toChatPromptResponse({
ok: true,
value: {
operationId: "operation-2",
kind: "run",
status: "failed",
error: { code: "provider", message: "failed", details: { status: 500 } },
fromTipId: null,
tipId: "entry-1",
startedAt: 1,
endedAt: 2,
},
}),
).toEqual({
accepted: true,
operationId: "operation-2",
error: { code: "provider", message: "failed" },
});
});
test.each(admissionErrors)("maps admission error %# to a stable response", (error, code, operationId) => {
expect(toChatPromptResponse({ ok: false, error })).toEqual({
accepted: false,
operationId,
error: { code, message: error.message },
});
});
});

View file

@ -110,6 +110,18 @@ describe("experimental CLI commands", () => {
});
});
test.each([
["-c", "continue"],
["--continue", "continue"],
["-r", "resume"],
["--resume", "resume"],
] as const)("parses client Session selection %s", (option, property) => {
expect(cli.parse(["client", option])).toEqual({
ok: true,
command: { command: "client", [property]: true },
});
});
test("parses a client session ID", () => {
expect(cli.parse(["client", "--session-id", "demo-1"])).toEqual({
ok: true,
@ -214,6 +226,7 @@ describe("experimental CLI commands", () => {
],
[["client", "--connect", "ws://localhost:8080"], 'Unsupported --connect transport "ws:"'],
[["client", "--provider", "anthropic"], "--provider requires --model"],
[["client", "-c", "-r"], "--session-id, --continue, and --resume are mutually exclusive"],
[["server", "--provider", "anthropic"], "--provider requires --model"],
[["server", "--server-id", "not-a-uuid"], "Invalid --server-id"],
[["server", "--server-id"], "--server-id requires a value"],

View file

@ -6,13 +6,14 @@ import {
RemoteServiceProvider,
replicatedState,
} from "@earendil-works/pi-agent-core";
import type { SessionSummary } from "@earendil-works/pi-protocol";
import type { LaneEvent, LaneSnapshot, SessionSummary } from "@earendil-works/pi-protocol";
import { ProcessTerminal, TuiMainScreen } from "@earendil-works/pi-tui";
import { describe, expect, test, vi } from "vitest";
import { type ClientTuiServer, ExperimentalClientTui } from "../src/experimental/client-tui.ts";
import type { FacetLoader } from "../src/experimental/facet-loader.ts";
import { defineFacet } from "../src/experimental/facets.ts";
import { Chat } from "../src/experimental/services/chat.ts";
import { createChatService } from "../src/experimental/services/chat-provider.ts";
import { AgentController } from "../src/experimental/services/agent-controller.ts";
import { createAgentController } from "../src/experimental/services/agent-controller-provider.ts";
import type {
ServerConnectionState,
ServerServiceConnection,
@ -33,180 +34,277 @@ function session(sessionId: string, createdAt: number): SessionSummary {
return { serverId, sessionId, createdAt };
}
function laneSnapshot(): LaneSnapshot {
return {
lane: "main",
transcript: [],
tipId: null,
configuration: {
model: { provider: "test", modelId: "one" },
thinkingLevel: "off",
activeToolNames: [],
},
stats: {
messageCount: 0,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
},
operation: null,
queues: [],
faulted: false,
};
}
describe("experimental client TUI", () => {
test("creates a Session, selects a model, and runs a turn through facet service handles", async () => {
const directoryState = replicatedState<SessionDirectoryState>({ revision: 1, sessions: [session("one", 1)] });
const attachment = replicatedState<SessionAttachmentState>({ status: "detached" });
const modelsState = replicatedState<ModelsState>({
catalog: {
revision: 1,
availableModels: [
{ provider: "test", modelId: "one", name: "Model One", reasoning: false },
{ provider: "test", modelId: "two", name: "Model Two", reasoning: true },
],
},
configuration: { model: { provider: "test", modelId: "one" }, thinkingLevel: "off" },
refresh: { status: "idle" },
});
const create = vi.fn(async () => {
const created = session("two", 2);
directoryState.set({ revision: 2, sessions: [...directoryState.value.sessions, created] }, BACKGROUND_CONTEXT);
return created;
});
const select = vi.fn(async (model: { provider: string; modelId: string }) => {
modelsState.set(
{ ...modelsState.value, configuration: { ...modelsState.value.configuration, model } },
BACKGROUND_CONTEXT,
);
});
const prompt = vi.fn(async () => ({
ok: true as const,
value: {
operation: "run" as const,
kind: "completed" as const,
runId: "run-1",
tipId: "entry-1",
},
}));
const serverProvider = new RemoteServiceProvider([SessionDirectory, SessionManagement]);
serverProvider.provide(SessionDirectory, { state: directoryState });
serverProvider.provide(SessionManagement, {
create,
async remove() {},
async attach(sessionId) {
attachment.set({ status: "attaching", sessionId }, BACKGROUND_CONTEXT);
},
async detach() {
attachment.set({ status: "detached" }, BACKGROUND_CONTEXT);
},
});
const sessionProvider = new RemoteServiceProvider([Models, Chat]);
sessionProvider.provide(Models, {
state: modelsState,
async cycleThinking() {},
async refresh() {},
select,
});
sessionProvider.provide(Chat, createChatService({ prompt } as unknown as AgentLane));
const serverNamespace = new RemoteServiceNamespace({
services: [SessionDirectory, SessionManagement],
connection: createLoopbackServiceConnection(serverProvider),
bound: false,
});
const serverServices: ServerServiceConnection = Object.assign(serverNamespace, {
acceptsUnavailableServices: false,
connection: replicatedState<ServerConnectionState>({ status: "connected", since: "now" }),
async catalogue() {
return serverProvider.catalogue;
},
open() {
return serverNamespace;
},
async activate() {
await serverNamespace.rebind(true, BACKGROUND_CONTEXT);
await serverNamespace.ready(BACKGROUND_CONTEXT);
},
});
const sessionNamespace = new RemoteServiceNamespace({
services: [Models, Chat],
connection: createLoopbackServiceConnection(sessionProvider),
bound: false,
});
const sessionServices: SessionServiceConnection = Object.assign(sessionNamespace, {
acceptsUnavailableServices: true,
attachment,
async catalogue() {
return [];
},
open() {
return sessionNamespace;
},
async activate() {
await sessionNamespace.ready(BACKGROUND_CONTEXT);
},
async whenAttached(sessionId: string) {
await sessionNamespace.rebind(true, BACKGROUND_CONTEXT);
await sessionNamespace.ready(BACKGROUND_CONTEXT);
attachment.set({ status: "attached", sessionId }, BACKGROUND_CONTEXT);
},
async whenDetached() {
await sessionNamespace.rebind(false, BACKGROUND_CONTEXT);
attachment.set({ status: "detached" }, BACKGROUND_CONTEXT);
},
});
const server: ClientTuiServer = { serverId, server: serverServices, session: sessionServices };
let finished = false;
const requestRender = vi.fn();
const disposeLoadedFacets = vi.fn(async () => {});
const facetLoader: FacetLoader = {
async load() {
return {
facets: [
defineFacet({
id: "test-tui-facet",
setup(env) {
const tui = env.use(Tui);
env.onActivate(() => tui.setStatus("Loaded test TUI facet"));
},
}),
test.each([
["new", { command: "client" as const }, "two", 1],
["continued", { command: "client" as const, continue: true }, "one", 0],
] as const)(
"opens a %s Session directly and renders a Harness-backed turn",
async (_kind, command, sessionId, creates) => {
const directoryState = replicatedState<SessionDirectoryState>({ revision: 1, sessions: [session("one", 1)] });
const attachment = replicatedState<SessionAttachmentState>({ status: "detached" });
const modelsState = replicatedState<ModelsState>({
catalog: {
revision: 1,
availableModels: [
{ provider: "test", modelId: "one", name: "Model One", reasoning: false },
{ provider: "test", modelId: "two", name: "Model Two", reasoning: true },
],
dispose: disposeLoadedFacets,
},
configuration: { model: { provider: "test", modelId: "one" }, thinkingLevel: "off" },
refresh: { status: "idle" },
});
const create = vi.fn(async () => {
const created = session("two", 2);
directoryState.set(
{ revision: 2, sessions: [...directoryState.value.sessions, created] },
BACKGROUND_CONTEXT,
);
return created;
});
const select = vi.fn(async (model: { provider: string; modelId: string }) => {
modelsState.set(
{ ...modelsState.value, configuration: { ...modelsState.value.configuration, model } },
BACKGROUND_CONTEXT,
);
});
let watchListener: ((event: LaneEvent) => void | Promise<void>) | undefined;
const snapshot = laneSnapshot();
const watchSession = vi.fn(async (sessionId: string) => ({
id: "watch-1",
sessionId,
snapshot,
async start(listener: (event: LaneEvent) => void | Promise<void>) {
watchListener = listener;
},
async resnapshot() {
return snapshot;
},
async dispose() {},
}));
const prompt = vi.fn(async () => {
await watchListener?.({
type: "entry_added",
lane: "main",
entry: {
id: "entry-user",
parentId: null,
seq: 1,
timestamp: 1,
type: "message",
message: { role: "user", content: [{ type: "text", text: "hello" }], timestamp: 1 },
},
});
await watchListener?.({
type: "entry_added",
lane: "main",
entry: {
id: "entry-assistant",
parentId: "entry-user",
seq: 2,
timestamp: 2,
type: "message",
message: {
role: "assistant",
content: [{ type: "text", text: "remote answer" }],
provider: "test",
model: "one",
api: "test",
usage: snapshot.stats.usage,
stopReason: "stop",
timestamp: 2,
},
},
});
return {
ok: true as const,
value: {
operationId: "run-1",
kind: "run" as const,
status: "completed" as const,
fromTipId: null,
tipId: "entry-assistant",
startedAt: 1,
endedAt: 2,
},
};
},
};
const component = await ExperimentalClientTui.create({
servers: [server],
facetLoader,
requestRender,
finish() {
finished = true;
},
});
try {
expect(component.render(80).join("\n")).toContain("one");
expect(component.render(80).join("\n")).toContain("Loaded test TUI facet");
});
component.handleInput("\r");
await vi.waitFor(() => expect(component.render(80).join("\n")).toContain("Experimental Models"));
expect(create).toHaveBeenCalledOnce();
expect(attachment.value).toEqual({ status: "attached", sessionId: "two" });
const serverProvider = new RemoteServiceProvider([SessionDirectory, SessionManagement]);
serverProvider.provide(SessionDirectory, { state: directoryState });
serverProvider.provide(SessionManagement, {
create,
async remove() {},
async attach(sessionId) {
attachment.set({ status: "attaching", sessionId }, BACKGROUND_CONTEXT);
},
async detach() {
attachment.set({ status: "detached" }, BACKGROUND_CONTEXT);
},
});
const sessionProvider = new RemoteServiceProvider([Models, AgentController]);
sessionProvider.provide(Models, {
state: modelsState,
async cycleThinking() {},
async refresh() {},
select,
});
sessionProvider.provide(AgentController, createAgentController({ prompt } as unknown as AgentLane));
component.handleInput("\u001b[B");
component.handleInput("\r");
await vi.waitFor(() =>
expect(select).toHaveBeenCalledWith({ provider: "test", modelId: "two" }, expect.anything()),
);
expect(modelsState.value.configuration.model).toEqual({ provider: "test", modelId: "two" });
await vi.waitFor(() => expect(component.render(80).join("\n")).toContain("Experimental Chat"));
expect(component.render(80).join("\n")).toContain("Selected test/two");
const serverNamespace = new RemoteServiceNamespace({
services: [SessionDirectory, SessionManagement],
connection: createLoopbackServiceConnection(serverProvider),
bound: false,
});
const serverServices: ServerServiceConnection = Object.assign(serverNamespace, {
acceptsUnavailableServices: false,
connection: replicatedState<ServerConnectionState>({ status: "connected", since: "now" }),
async catalogue() {
return serverProvider.catalogue;
},
open() {
return serverNamespace;
},
async activate() {
await serverNamespace.rebind(true, BACKGROUND_CONTEXT);
await serverNamespace.ready(BACKGROUND_CONTEXT);
},
});
const sessionNamespace = new RemoteServiceNamespace({
services: [Models, AgentController],
connection: createLoopbackServiceConnection(sessionProvider),
bound: false,
});
const sessionServices: SessionServiceConnection = Object.assign(sessionNamespace, {
acceptsUnavailableServices: true,
attachment,
async catalogue() {
return [];
},
open() {
return sessionNamespace;
},
async activate() {
await sessionNamespace.ready(BACKGROUND_CONTEXT);
},
async whenAttached(sessionId: string) {
await sessionNamespace.rebind(true, BACKGROUND_CONTEXT);
await sessionNamespace.ready(BACKGROUND_CONTEXT);
attachment.set({ status: "attached", sessionId }, BACKGROUND_CONTEXT);
},
async whenDetached() {
await sessionNamespace.rebind(false, BACKGROUND_CONTEXT);
attachment.set({ status: "detached" }, BACKGROUND_CONTEXT);
},
});
const server: ClientTuiServer = {
serverId,
laneWatches: { watchSession },
server: serverServices,
session: sessionServices,
};
let finished = false;
const requestRender = vi.fn();
const disposeLoadedFacets = vi.fn(async () => {});
const facetLoader: FacetLoader = {
async load() {
return {
facets: [
defineFacet({
id: "test-tui-facet",
setup(env) {
const tui = env.use(Tui);
env.onActivate(() => tui.setStatus("Loaded test TUI facet"));
},
}),
],
dispose: disposeLoadedFacets,
};
},
};
const ui = new TuiMainScreen(new ProcessTerminal());
const component = await ExperimentalClientTui.create({
command,
ui,
servers: [server],
facetLoader,
requestRender,
finish() {
finished = true;
},
});
try {
expect(create).toHaveBeenCalledTimes(creates);
expect(attachment.value).toEqual({ status: "attached", sessionId });
expect(watchSession).toHaveBeenCalledWith(sessionId);
expect(select).not.toHaveBeenCalled();
expect(component.render(80).join("\n")).toContain("test/one");
expect(component.render(80).join("\n")).not.toContain("Experimental Sessions");
expect(component.render(80).join("\n")).not.toContain("Experimental Models");
component.handleInput("hello");
expect(component.render(80).join("\n")).toContain("hello");
component.handleInput("\r");
await vi.waitFor(() => expect(prompt).toHaveBeenCalledWith("hello", undefined, expect.anything()));
await vi.waitFor(() => expect(component.render(80).join("\n")).toContain("Turn run-1 completed."));
component.handleInput("hello");
component.handleInput("\r");
await vi.waitFor(() => expect(prompt).toHaveBeenCalledWith("hello", undefined, expect.anything()));
await vi.waitFor(() => expect(component.render(80).join("\n")).toContain("remote answer"));
expect(component.render(80).join("\n")).toContain("hello");
component.handleInput("\u001b");
expect(component.render(80).join("\n")).toContain("Experimental Models");
component.handleInput("\u001b");
expect(component.render(80).join("\n")).toContain("Experimental Sessions");
component.handleInput("\u001b");
expect(finished).toBe(true);
component.handleInput("/model");
component.handleInput("\r");
await vi.waitFor(() => expect(component.render(80).join("\n")).toContain("Select model:"));
component.handleInput("\u001b[B");
component.handleInput("\r");
await vi.waitFor(() =>
expect(select).toHaveBeenCalledWith({ provider: "test", modelId: "two" }, expect.anything()),
);
expect(modelsState.value.configuration.model).toEqual({ provider: "test", modelId: "two" });
await vi.waitFor(() => expect(component.render(80).join("\n")).not.toContain("Select model:"));
await component.close();
expect(disposeLoadedFacets).toHaveBeenCalledOnce();
const rendersAfterClose = requestRender.mock.calls.length;
directoryState.set({ revision: 3, sessions: [] }, BACKGROUND_CONTEXT);
attachment.set({ status: "detached" }, BACKGROUND_CONTEXT);
modelsState.set({ ...modelsState.value, refresh: { status: "refreshing" } }, BACKGROUND_CONTEXT);
expect(requestRender).toHaveBeenCalledTimes(rendersAfterClose);
} finally {
await component.close();
await Promise.all([serverNamespace.dispose(BACKGROUND_CONTEXT), sessionNamespace.dispose(BACKGROUND_CONTEXT)]);
serverProvider.dispose();
sessionProvider.dispose();
}
});
component.handleInput("\u0003");
await vi.waitFor(() => expect(finished).toBe(true));
await component.close();
expect(disposeLoadedFacets).toHaveBeenCalledOnce();
const rendersAfterClose = requestRender.mock.calls.length;
directoryState.set({ revision: 3, sessions: [] }, BACKGROUND_CONTEXT);
attachment.set({ status: "detached" }, BACKGROUND_CONTEXT);
modelsState.set({ ...modelsState.value, refresh: { status: "refreshing" } }, BACKGROUND_CONTEXT);
expect(requestRender).toHaveBeenCalledTimes(rendersAfterClose);
} finally {
await component.close();
await Promise.all([
serverNamespace.dispose(BACKGROUND_CONTEXT),
sessionNamespace.dispose(BACKGROUND_CONTEXT),
]);
serverProvider.dispose();
sessionProvider.dispose();
}
},
);
});

View file

@ -0,0 +1,77 @@
import type { LaneEvent, LaneSnapshot } from "@earendil-works/pi-protocol";
import { describe, expect, test, vi } from "vitest";
import { openLaneReplica } from "../src/experimental/lane-replica.ts";
function snapshot(tipId: string | null = null): LaneSnapshot {
return {
lane: "main",
transcript: [],
tipId,
configuration: {
model: { provider: "test", modelId: "model" },
thinkingLevel: "off",
activeToolNames: [],
},
stats: {
messageCount: 0,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
},
operation: null,
queues: [],
faulted: false,
};
}
describe("experimental lane replica", () => {
test("uses the Harness reducer and resnapshots navigation rebases", async () => {
let listener: ((event: LaneEvent) => void | Promise<void>) | undefined;
const replacement = snapshot("replacement-tip");
const resnapshot = vi.fn(async () => replacement);
const dispose = vi.fn(async () => {});
const replica = await openLaneReplica(
{
async watchSession(sessionId) {
return {
id: "watch-1",
sessionId,
snapshot: snapshot(),
async start(next) {
listener = next;
},
resnapshot,
dispose,
};
},
},
"session-1",
);
const changed = vi.fn();
replica.subscribe(changed);
await listener?.({ type: "run_start", lane: "main", runId: "run-1", startedAt: 1 });
expect(replica.state().operation).toMatchObject({ id: "run-1", kind: "run" });
expect(changed).toHaveBeenCalledOnce();
await listener?.({
type: "navigation_end",
lane: "main",
runId: "navigation-1",
status: "completed",
fromTipId: null,
tipId: "replacement-tip",
endedAt: 2,
});
expect(resnapshot).toHaveBeenCalledOnce();
expect(replica.state().tipId).toBe("replacement-tip");
await replica.close();
expect(dispose).toHaveBeenCalledOnce();
});
});

View file

@ -135,7 +135,7 @@ describe("experimental durable server composition", () => {
expect(state.activeTools).toEqual(["read", "write", "bash"]);
});
test("applies an explicit model when opening an existing Session", async () => {
test("preserves an existing Session model when the server default changes", async () => {
await writeFile(
join(agentDir, "settings.json"),
JSON.stringify({ defaultProvider: "anthropic", defaultModel: "claude-opus-4-6" }),
@ -157,7 +157,7 @@ describe("experimental durable server composition", () => {
clients.delete(secondClient);
await expect.poll(() => second.workerPids.has("demo-1")).toBe(false);
const state = await readExperimentalSessionState(second.sessionDir, "demo-1");
expect(state.model).toEqual({ provider: "anthropic", modelId: "claude-sonnet-4-5" });
expect(state.model).toEqual({ provider: "anthropic", modelId: "claude-opus-4-6" });
});
test.each(["discovery", "explicit connection"] as const)(
@ -405,6 +405,32 @@ describe("experimental durable server composition", () => {
await Promise.all([firstServices.dispose(BACKGROUND_CONTEXT), secondServices.dispose(BACKGROUND_CONTEXT)]);
});
test("uses the most recently selected model for a new Session", async () => {
const directory = await mkdtemp(join("/tmp", "pes-model-default-"));
directories.add(directory);
const runtime = await startServer({ directory });
servers.add(runtime);
const firstClient = await attachClient(runtime, "demo-1");
const firstServices = createSessionServiceNamespace(firstClient, { services: [Models] });
const firstModels = firstServices.use(Models);
await firstServices.ready(BACKGROUND_CONTEXT);
await firstModels.select({ provider: "anthropic", modelId: "claude-opus-4-6" }, BACKGROUND_CONTEXT);
await firstServices.dispose(BACKGROUND_CONTEXT);
await firstClient.dispose();
clients.delete(firstClient);
await expect.poll(() => runtime.workerPids.has("demo-1")).toBe(false);
const secondClient = await attachClient(runtime, "demo-2");
const secondServices = createSessionServiceNamespace(secondClient, { services: [Models] });
const secondModels = secondServices.use(Models);
await secondServices.ready(BACKGROUND_CONTEXT);
expect(secondModels.state.value?.configuration.model).toEqual({
provider: "anthropic",
modelId: "claude-opus-4-6",
});
await secondServices.dispose(BACKGROUND_CONTEXT);
});
test("composes management attachment with Session service hydration", async () => {
const { runtime } = await makeServer();
const clientRuntime = await openClientRuntime({

View file

@ -14,7 +14,7 @@ The transport carries `{ serviceId, instance?, member, args }` calls. A server t
`PromptArguments` contains one serializable `AgentLane.prompt()` overload. `PromptMessage` is the protocol's closed set of built-in message DTOs; application-defined `AgentMessage` extensions are not accepted implicitly. `RunResult` is the wire-safe structural equivalent of the Harness result and contains no JavaScript `Error` instances.
The temporary `ServiceRpc` manifest still backs low-level client compatibility methods while those features migrate to services. Experimental presentations consume server-owned `SessionDirectory` and `SessionManagement` plus provider-generated Session service catalogues; there is no handwritten worker service inventory. `Models` has an implemented provider. `Chat` is wired to `AgentLane`; `Accounts` and revisioned `Transcript` retain placeholder providers while those features are completed. Session service calls route opaquely to the attached worker, where the provider validates and invokes them.
The temporary `ServiceRpc` manifest still backs low-level client compatibility methods while those features migrate to services. Experimental presentations consume server-owned `SessionDirectory` and `SessionManagement` plus provider-generated Session service catalogues; there is no handwritten worker service inventory. `Models` has an implemented provider. `AgentController` is the presentation-safe command facade over the worker-owned main `AgentLane`; `Accounts` and revisioned `Transcript` retain placeholder providers while those features are completed. Session service calls route opaquely to the attached worker, where the provider validates and invokes them.
A lane watch uses three RPCs so snapshot ordering does not depend on response/event scheduling: `watch()` creates a buffering watch and returns its authoritative snapshot, `startWatch()` flushes buffered events and begins live delivery, and `stopWatch()` releases it. Events carry their watch ID. Streaming message updates carry compact assistant-message frames rather than cumulative partial messages. Reconnection creates a new watch and snapshot rather than replaying old events.