chore(vendor): import pi 0.85.1
Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
This commit is contained in:
12
third_party/pi/packages/coding-agent/src/experimental/cli.ts
vendored
Normal file
12
third_party/pi/packages/coding-agent/src/experimental/cli.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env node
|
||||
import { setupCli } from "../cli/setup.ts";
|
||||
import { main } from "../main.ts";
|
||||
import { runExperimentalCommand } from "./commands.ts";
|
||||
|
||||
setupCli();
|
||||
const args = process.argv.slice(2);
|
||||
if (await runExperimentalCommand(args)) {
|
||||
if (args[0] === "client") process.exit(process.exitCode ?? 0);
|
||||
} else {
|
||||
await main(args);
|
||||
}
|
||||
230
third_party/pi/packages/coding-agent/src/experimental/client-runtime.ts
vendored
Normal file
230
third_party/pi/packages/coding-agent/src/experimental/client-runtime.ts
vendored
Normal file
@@ -0,0 +1,230 @@
|
||||
import { basename } from "node:path";
|
||||
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
||||
import { Client, ServerError } from "@earendil-works/pi-client";
|
||||
import { createUnixTransportFactory, discoverUnixServers, type UnixServerRoute } from "@earendil-works/pi-client/unix";
|
||||
import { isServerId, type ServerId } from "@earendil-works/pi-protocol";
|
||||
import type { ClientCommand } from "../cli/experimental/commands/client.ts";
|
||||
import { RadiusRelayAuthResolver } from "./radius-auth.ts";
|
||||
import { createRadiusClientTransportFactory, RadiusClientReconnect } from "./radius-relay.ts";
|
||||
import { activateServer, ENV_SERVER_ID, resolveServerDirectory, resolveSessionDirectory } from "./server.ts";
|
||||
import { AgentController } from "./services/agent-controller.ts";
|
||||
import {
|
||||
createServerServiceSource,
|
||||
createSessionServiceSource,
|
||||
type ServerServiceSource,
|
||||
type SessionServiceSource,
|
||||
} from "./services/connection.ts";
|
||||
import { Models } from "./services/models.ts";
|
||||
import { PresentationPlugins } from "./services/plugins.ts";
|
||||
import { SessionDirectory, SessionManagement } from "./services/sessions.ts";
|
||||
import { Transcript } from "./services/transcript.ts";
|
||||
|
||||
export type ClientRuntimeRoute =
|
||||
| ({ readonly transport: "unix" } & UnixServerRoute)
|
||||
| { readonly transport: "radius"; readonly serverId: ServerId };
|
||||
|
||||
export interface ClientRuntimeServer {
|
||||
readonly route: ClientRuntimeRoute;
|
||||
readonly client: Client;
|
||||
readonly server: ServerServiceSource;
|
||||
readonly session: SessionServiceSource;
|
||||
}
|
||||
|
||||
export interface ActivatedClientRuntimeServer extends ClientRuntimeServer {
|
||||
readonly directory: SessionDirectory;
|
||||
readonly management: SessionManagement;
|
||||
readonly plugins: PresentationPlugins;
|
||||
readonly models: Models;
|
||||
readonly agent: AgentController;
|
||||
readonly transcript: Transcript;
|
||||
}
|
||||
|
||||
export interface ClientRuntime {
|
||||
readonly servers: readonly ClientRuntimeServer[];
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface OpenClientRuntimeOptions {
|
||||
/** Directory searched when --connect is omitted. Defaults to PI_SERVER_DIR or ~/.pi/server. */
|
||||
readonly directory?: string;
|
||||
}
|
||||
|
||||
/** Open live server/session service namespaces for one experimental presentation. */
|
||||
export async function openClientRuntime(
|
||||
command: ClientCommand,
|
||||
options: OpenClientRuntimeOptions = {},
|
||||
): Promise<ClientRuntime> {
|
||||
if (command.auth !== undefined && command.connect?.transport !== "radius") {
|
||||
throw new Error("Authentication is only supported for experimental Radius connections");
|
||||
}
|
||||
if (command.provider !== undefined && command.model === undefined) {
|
||||
throw new Error("Server model provider requires a model");
|
||||
}
|
||||
if (command.connect && command.model !== undefined) {
|
||||
throw new Error("Model selection is only valid when automatically activating a new server");
|
||||
}
|
||||
if (command.connect?.transport === "radius" && command.pluginPackages !== undefined) {
|
||||
throw new Error("Plugin package paths can only be configured on a local Unix server");
|
||||
}
|
||||
const directory = resolveServerDirectory(options.directory);
|
||||
let routes: ClientRuntimeRoute[];
|
||||
let activatedClient: Client | undefined;
|
||||
if (command.connect) {
|
||||
routes = [
|
||||
command.connect.transport === "radius"
|
||||
? { transport: "radius", serverId: command.connect.serverId }
|
||||
: { transport: "unix", ...routeFromExplicitPath(command.connect.path) },
|
||||
];
|
||||
} else {
|
||||
routes = (await discoverUnixServers({ directory })).map((route) => ({ transport: "unix", ...route }));
|
||||
if (routes.length > 0 && command.model !== undefined) {
|
||||
throw new Error("Model selection is only valid when automatically activating a new server");
|
||||
}
|
||||
if (routes.length === 0) {
|
||||
const activated = await activateServer({
|
||||
directory,
|
||||
requestedServerId: process.env[ENV_SERVER_ID],
|
||||
sessionDir: resolveSessionDirectory(),
|
||||
provider: command.provider,
|
||||
model: command.model,
|
||||
});
|
||||
routes = [{ transport: "unix", ...activated.route }];
|
||||
activatedClient = activated.client;
|
||||
}
|
||||
}
|
||||
if (command.pluginPackages !== undefined && routes.length !== 1) {
|
||||
throw new Error("Plugin selection requires exactly one local server");
|
||||
}
|
||||
|
||||
const clients: Client[] = [];
|
||||
const reconnectors: RadiusClientReconnect[] = [];
|
||||
const serviceSources: Array<ServerServiceSource | SessionServiceSource> = [];
|
||||
const servers: ClientRuntimeServer[] = [];
|
||||
let disposed = false;
|
||||
const dispose = async (): Promise<void> => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
const reconnectResults = await Promise.allSettled(reconnectors.map((reconnector) => reconnector.dispose()));
|
||||
const sourceResults = await Promise.allSettled(
|
||||
serviceSources.map((source) => source.dispose(BACKGROUND_CONTEXT)),
|
||||
);
|
||||
const clientResults = await Promise.allSettled(clients.map((client) => client.dispose()));
|
||||
const errors = [...reconnectResults, ...sourceResults, ...clientResults].flatMap((result) =>
|
||||
result.status === "rejected" ? [result.reason] : [],
|
||||
);
|
||||
if (errors.length === 1) throw errors[0];
|
||||
if (errors.length > 1) throw new AggregateError(errors, "Failed to dispose experimental client runtime");
|
||||
};
|
||||
|
||||
try {
|
||||
for (const route of routes) {
|
||||
let client = activatedClient;
|
||||
if (client === undefined) {
|
||||
try {
|
||||
client = await Client.connect({
|
||||
serverId: route.serverId,
|
||||
transportFactory:
|
||||
route.transport === "unix"
|
||||
? createUnixTransportFactory({ path: route.path })
|
||||
: createRadiusClientTransportFactory({
|
||||
serverId: route.serverId,
|
||||
auth: new RadiusRelayAuthResolver(command.auth),
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
command.connect !== undefined ||
|
||||
route.transport !== "unix" ||
|
||||
!(error instanceof ServerError) ||
|
||||
error.code !== "version"
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
client = (
|
||||
await activateServer({
|
||||
directory,
|
||||
requestedServerId: route.serverId,
|
||||
sessionDir: resolveSessionDirectory(),
|
||||
})
|
||||
).client;
|
||||
}
|
||||
}
|
||||
activatedClient = undefined;
|
||||
clients.push(client);
|
||||
const server = createServerServiceSource(client);
|
||||
serviceSources.push(server);
|
||||
if (route.transport === "radius") {
|
||||
const reconnectServices = server.open({
|
||||
services: [SessionManagement],
|
||||
assertAccess() {},
|
||||
onError() {},
|
||||
});
|
||||
const reconnectManagement = reconnectServices.use(SessionManagement);
|
||||
reconnectors.push(
|
||||
new RadiusClientReconnect(client, async (sessionId) => {
|
||||
await reconnectServices.ready(BACKGROUND_CONTEXT);
|
||||
await reconnectManagement.attach(sessionId, BACKGROUND_CONTEXT);
|
||||
}),
|
||||
);
|
||||
}
|
||||
const session = createSessionServiceSource(client);
|
||||
serviceSources.push(session);
|
||||
servers.push({ route, client, server, session });
|
||||
}
|
||||
return { servers, dispose };
|
||||
} catch (error) {
|
||||
try {
|
||||
await dispose();
|
||||
} catch (cleanupError) {
|
||||
throw new AggregateError([error, cleanupError], "Experimental client startup and cleanup failed");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Acquire and connect the built-in service facades used by the non-interactive client. */
|
||||
export async function activateBuiltinClientServices(
|
||||
server: ClientRuntimeServer,
|
||||
): Promise<ActivatedClientRuntimeServer> {
|
||||
const serverServices = server.server.open({
|
||||
services: [SessionDirectory, SessionManagement, PresentationPlugins],
|
||||
assertAccess() {},
|
||||
onError() {},
|
||||
});
|
||||
const sessionServices = server.session.open({
|
||||
services: [Models, AgentController, Transcript],
|
||||
assertAccess() {},
|
||||
onError() {},
|
||||
});
|
||||
const directory = serverServices.use(SessionDirectory);
|
||||
const remoteManagement = serverServices.use(SessionManagement);
|
||||
const management: SessionManagement = {
|
||||
create: (options, context) => remoteManagement.create(options, context),
|
||||
async remove(sessionId, context) {
|
||||
const removesCurrentAttachment = server.client.attachment?.sessionId === sessionId;
|
||||
await remoteManagement.remove(sessionId, context);
|
||||
if (removesCurrentAttachment) await server.session.whenDetached(context);
|
||||
},
|
||||
async attach(sessionId, context) {
|
||||
await remoteManagement.attach(sessionId, context);
|
||||
await server.session.whenAttached(sessionId, context);
|
||||
},
|
||||
async detach(context) {
|
||||
await remoteManagement.detach(context);
|
||||
await server.session.whenDetached(context);
|
||||
},
|
||||
};
|
||||
const plugins = serverServices.use(PresentationPlugins);
|
||||
const models = sessionServices.use(Models);
|
||||
const agent = sessionServices.use(AgentController);
|
||||
const transcript = sessionServices.use(Transcript);
|
||||
await Promise.all([serverServices.ready(BACKGROUND_CONTEXT), sessionServices.ready(BACKGROUND_CONTEXT)]);
|
||||
return { ...server, directory, management, plugins, models, agent, transcript };
|
||||
}
|
||||
|
||||
function routeFromExplicitPath(path: string): UnixServerRoute {
|
||||
const name = basename(path);
|
||||
const serverId = name.endsWith(".sock") ? name.slice(0, -".sock".length) : "";
|
||||
if (!isServerId(serverId)) throw new Error("--connect path must end with <uuidv4-server-id>.sock");
|
||||
return { serverId, path };
|
||||
}
|
||||
184
third_party/pi/packages/coding-agent/src/experimental/client-tui-chat.ts
vendored
Normal file
184
third_party/pi/packages/coding-agent/src/experimental/client-tui-chat.ts
vendored
Normal file
@@ -0,0 +1,184 @@
|
||||
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 {
|
||||
static readonly #renderers: Record<string, ToolRenderers> = createAllToolRenderers();
|
||||
|
||||
readonly transcript = new Container();
|
||||
readonly pendingMessages = new Container();
|
||||
readonly status = new Container();
|
||||
readonly #ui: TUI;
|
||||
readonly #cwd: string;
|
||||
readonly #tools = new Map<string, ToolExecutionComponent>();
|
||||
#renderedEntryIds: string[] = [];
|
||||
#streaming: AssistantMessageComponent | undefined;
|
||||
#indicator: StatusIndicator | undefined;
|
||||
#working = false;
|
||||
|
||||
constructor(ui: TUI, cwd: string) {
|
||||
this.#ui = ui;
|
||||
this.#cwd = cwd;
|
||||
}
|
||||
|
||||
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);
|
||||
if (tool.status === "running") {
|
||||
component.markExecutionStarted();
|
||||
if (tool.result !== undefined) component.updateResult({ ...tool.result, isError: false }, true);
|
||||
} else {
|
||||
component.updateResult({ ...tool.result, isError: tool.isError }, false);
|
||||
}
|
||||
}
|
||||
this.#syncQueues(snapshot.queues);
|
||||
this.#setWorking(snapshot.operation !== null);
|
||||
this.transcript.invalidate();
|
||||
this.pendingMessages.invalidate();
|
||||
this.status.invalidate();
|
||||
}
|
||||
|
||||
refreshTheme(snapshot: LaneSnapshot): void {
|
||||
this.#indicator?.dispose();
|
||||
this.#indicator = undefined;
|
||||
this.#working = false;
|
||||
this.transcript.clear();
|
||||
this.pendingMessages.clear();
|
||||
this.status.clear();
|
||||
this.#tools.clear();
|
||||
this.#renderedEntryIds = [];
|
||||
this.#streaming = undefined;
|
||||
this.apply(snapshot);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.#indicator?.dispose();
|
||||
}
|
||||
|
||||
#syncQueues(queues: LaneSnapshot["queues"]): void {
|
||||
this.pendingMessages.clear();
|
||||
for (const item of queues) {
|
||||
const text =
|
||||
item.type === "message" ? userMessageText(item.message).replace(/\s+/g, " ") : `<${item.customType}>`;
|
||||
this.pendingMessages.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.transcript.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.transcript.addChild(new Spacer(1));
|
||||
this.transcript.addChild(new UserMessageComponent(userMessageText(message)));
|
||||
return;
|
||||
}
|
||||
if (message.role === "assistant") {
|
||||
const component = this.#streaming ?? new AssistantMessageComponent();
|
||||
if (!this.#streaming) this.transcript.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.transcript.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.transcript.addChild(component);
|
||||
this.#tools.set(toolCallId, component);
|
||||
return component;
|
||||
}
|
||||
|
||||
#addText(text: string): void {
|
||||
this.transcript.addChild(new Spacer(1));
|
||||
this.transcript.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
804
third_party/pi/packages/coding-agent/src/experimental/client-tui.ts
vendored
Normal file
804
third_party/pi/packages/coding-agent/src/experimental/client-tui.ts
vendored
Normal file
@@ -0,0 +1,804 @@
|
||||
import { resolve } from "node:path";
|
||||
import {
|
||||
combineFacetLoaders,
|
||||
createFacetHost,
|
||||
defineFacet,
|
||||
type FacetHost,
|
||||
type FacetLoader,
|
||||
type JsonValue,
|
||||
type LoadedFacets,
|
||||
} from "@earendil-works/chord";
|
||||
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
||||
import {
|
||||
CombinedAutocompleteProvider,
|
||||
type Component,
|
||||
Container,
|
||||
type SelectItem,
|
||||
SelectList,
|
||||
setKeybindings,
|
||||
Text,
|
||||
type TUI,
|
||||
} from "@earendil-works/pi-tui";
|
||||
import type { ClientCommand } from "../cli/experimental/commands/client.ts";
|
||||
import { getAgentDir } from "../config.ts";
|
||||
import { KeybindingsManager } from "../core/keybindings.ts";
|
||||
import { DefaultResourceLoader } from "../core/resource-loader.ts";
|
||||
import { SettingsManager } from "../core/settings-manager.ts";
|
||||
import { createChatViewport } from "../modes/interactive/chat-viewport.ts";
|
||||
import { CustomEditor } from "../modes/interactive/components/custom-editor.ts";
|
||||
import { getEditorTheme, setRegisteredThemes, stopThemeWatcher, theme } from "../modes/interactive/theme/theme.ts";
|
||||
import { InteractiveThemeController } from "../modes/interactive/theme/theme-controller.ts";
|
||||
import { createInteractiveTui } from "../modes/interactive/tui-renderer.ts";
|
||||
import { type OpenClientRuntimeOptions, openClientRuntime } from "./client-runtime.ts";
|
||||
import { ExperimentalChatView } from "./client-tui-chat.ts";
|
||||
import { createPresentationFacetLoaders } from "./plugins/bundled.ts";
|
||||
import { AgentController, type AgentOperationResponse, type AgentQueueResponse } from "./services/agent-controller.ts";
|
||||
import type {
|
||||
ServerConnectionState,
|
||||
ServerServiceSource,
|
||||
SessionAttachmentState,
|
||||
SessionServiceSource,
|
||||
} from "./services/connection.ts";
|
||||
import { PresentationPlugins } from "./services/plugins.ts";
|
||||
import { PresentationUI } from "./services/presentation-ui.ts";
|
||||
import { SessionDirectory, SessionManagement, type SessionSummary } from "./services/sessions.ts";
|
||||
import { SlashCommands } from "./services/slash-commands.ts";
|
||||
import {
|
||||
createBuiltInSlashCommandsFacet,
|
||||
createSlashCommandsRuntimeFacet,
|
||||
} from "./services/slash-commands-provider.ts";
|
||||
import { Transcript, type Transcript as TranscriptService } from "./services/transcript.ts";
|
||||
|
||||
export interface RunClientTuiOptions extends OpenClientRuntimeOptions {
|
||||
readonly facetLoader?: FacetLoader;
|
||||
}
|
||||
|
||||
export interface ClientTuiServer {
|
||||
readonly serverId: string;
|
||||
readonly radius: boolean;
|
||||
readonly server: ServerServiceSource;
|
||||
readonly session: SessionServiceSource;
|
||||
}
|
||||
|
||||
interface SessionFeature {
|
||||
readonly serverId: string;
|
||||
readonly session: SessionServiceSource;
|
||||
readonly transcript: TranscriptService;
|
||||
}
|
||||
|
||||
interface PreparedClientSession {
|
||||
readonly server: ClientTuiServer;
|
||||
readonly summary: SessionSummary;
|
||||
readonly presentationPlugins: JsonValue;
|
||||
}
|
||||
|
||||
interface PendingSelection {
|
||||
readonly title: string;
|
||||
readonly items: readonly SelectItem[];
|
||||
readonly selectedValue?: string;
|
||||
resolve(value: string | undefined): void;
|
||||
}
|
||||
|
||||
const selectTheme = {
|
||||
selectedPrefix: (text: string) => theme.fg("accent", text),
|
||||
selectedText: (text: string) => theme.fg("accent", text),
|
||||
description: (text: string) => theme.fg("muted", text),
|
||||
scrollInfo: (text: string) => theme.fg("dim", text),
|
||||
noMatch: (text: string) => theme.fg("warning", text),
|
||||
};
|
||||
|
||||
/** 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 #documentContainer = new Container();
|
||||
readonly #sessionHeading = new Text("", 1, 0);
|
||||
readonly #pendingMessagesContainer = new Container();
|
||||
readonly #statusContainer = new Container();
|
||||
readonly #editorContainer = new Container();
|
||||
readonly #footerComponent = new Text("", 1, 0);
|
||||
readonly #layoutRoot: Component;
|
||||
readonly #sharedFacets: LoadedFacets;
|
||||
readonly #keybindings = KeybindingsManager.create();
|
||||
#presentationFacets: LoadedFacets | undefined;
|
||||
#facetHost: FacetHost | undefined;
|
||||
#facetReloadTail = Promise.resolve();
|
||||
#session: SessionFeature | undefined;
|
||||
#slashCommands: SlashCommands | undefined;
|
||||
#controller: AgentController | undefined;
|
||||
readonly #chatInput: CustomEditor;
|
||||
#selectList: SelectList | undefined;
|
||||
#selection: PendingSelection | undefined;
|
||||
#screen: "select" | "chat" = "chat";
|
||||
#selectedServerId: string | undefined;
|
||||
#sessionId: string | undefined;
|
||||
#status = "Starting Session…";
|
||||
#busy = false;
|
||||
#closed = false;
|
||||
#closePromise: Promise<void> | undefined;
|
||||
#recoveryTransition: Promise<void> = Promise.resolve();
|
||||
#laneUnsubscribe: (() => void) | undefined;
|
||||
#chatView: ExperimentalChatView | undefined;
|
||||
|
||||
private constructor(ui: TUI, requestRender: () => void, finish: () => void, loadedFacets: LoadedFacets) {
|
||||
this.#ui = ui;
|
||||
this.#requestRender = requestRender;
|
||||
this.#finish = finish;
|
||||
this.#sharedFacets = loadedFacets;
|
||||
setKeybindings(this.#keybindings);
|
||||
this.#chatInput = new CustomEditor(ui, getEditorTheme(), this.#keybindings, { paddingX: 1 });
|
||||
this.#chatInput.onSubmit = (message) => void this.#runPrompt(message);
|
||||
this.#chatInput.onEscape = () => this.#interrupt();
|
||||
this.#chatInput.onCtrlD = finish;
|
||||
this.#chatInput.onAction("app.clear", finish);
|
||||
this.#chatInput.onAction("app.model.select", () => void this.#executeSlashCommand("model", ""));
|
||||
this.#chatInput.onAction("app.message.followUp", () => {
|
||||
const text = this.#chatInput.getText().trim();
|
||||
if (text.length === 0) return;
|
||||
this.#chatInput.setText("");
|
||||
void this.#queueFollowUp(text);
|
||||
});
|
||||
this.#editorContainer.addChild(this.#chatInput);
|
||||
this.#layoutRoot = createChatViewport({
|
||||
document: this.#documentContainer,
|
||||
pendingMessages: this.#pendingMessagesContainer,
|
||||
status: this.#statusContainer,
|
||||
editor: this.#editorContainer,
|
||||
footer: this.#footerComponent,
|
||||
scrollbarTrackStyle: (text) => theme.fg("scrollbarTrack", text),
|
||||
scrollbarThumbStyle: (text) => theme.fg("scrollbarThumb", text),
|
||||
}).root;
|
||||
this.#rebuild();
|
||||
}
|
||||
|
||||
static async create(options: {
|
||||
readonly command: ClientCommand;
|
||||
readonly ui: TUI;
|
||||
readonly servers: readonly ClientTuiServer[];
|
||||
readonly facetLoader?: FacetLoader;
|
||||
requestRender(): void;
|
||||
finish(): void;
|
||||
}): Promise<ExperimentalClientTui> {
|
||||
const prepared = await prepareClientSession(options.command, options.servers);
|
||||
const loadedFacets = await combineFacetLoaders(
|
||||
options.facetLoader === undefined ? [] : [options.facetLoader],
|
||||
).load();
|
||||
const component = new ExperimentalClientTui(options.ui, options.requestRender, options.finish, loadedFacets);
|
||||
try {
|
||||
await component.#start(prepared);
|
||||
await component.#openPreparedSession(prepared);
|
||||
return component;
|
||||
} catch (error) {
|
||||
try {
|
||||
await component.close();
|
||||
} catch (cleanupError) {
|
||||
throw new AggregateError([error, cleanupError], "Experimental TUI startup and cleanup failed");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
get layoutRoot(): Component {
|
||||
return this.#layoutRoot;
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
return [
|
||||
...this.#documentContainer.render(width),
|
||||
...this.#pendingMessagesContainer.render(width),
|
||||
...this.#statusContainer.render(width),
|
||||
...this.#editorContainer.render(width),
|
||||
...this.#footerComponent.render(width),
|
||||
];
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (this.#busy) {
|
||||
if (
|
||||
this.#keybindings.matches(data, "app.clear") ||
|
||||
(this.#chatInput.getText().length === 0 && this.#keybindings.matches(data, "app.exit"))
|
||||
) {
|
||||
this.#finish();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this.#screen === "chat") {
|
||||
this.#chatInput.handleInput(data);
|
||||
this.#requestRender();
|
||||
return;
|
||||
}
|
||||
this.#selectList?.handleInput(data);
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.#layoutRoot.invalidate();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
void this.close().catch(() => {});
|
||||
}
|
||||
|
||||
refreshTheme(): void {
|
||||
const snapshot = this.#laneSnapshot();
|
||||
if (snapshot !== undefined) this.#chatView?.refreshTheme(snapshot);
|
||||
this.#rebuild();
|
||||
}
|
||||
|
||||
showError(error: string): void {
|
||||
this.#status = `Error: ${error}`;
|
||||
this.#rebuild();
|
||||
}
|
||||
|
||||
close(): Promise<void> {
|
||||
this.#closePromise ??= this.#close();
|
||||
return this.#closePromise;
|
||||
}
|
||||
|
||||
async #start(prepared: PreparedClientSession): Promise<void> {
|
||||
const server = prepared.server;
|
||||
let presentationFacets = await combineFacetLoaders(
|
||||
createPresentationFacetLoaders(prepared.presentationPlugins),
|
||||
).load();
|
||||
this.#presentationFacets = presentationFacets;
|
||||
let facetHost!: FacetHost;
|
||||
const reloadPresentationPlugins = (data: JsonValue): Promise<void> => {
|
||||
const operation = this.#facetReloadTail.then(async () => {
|
||||
const candidate = await combineFacetLoaders(createPresentationFacetLoaders(data)).load();
|
||||
try {
|
||||
await facetHost.reload(candidate.facets);
|
||||
} catch (error) {
|
||||
try {
|
||||
await candidate.dispose();
|
||||
} catch (cleanupError) {
|
||||
throw new AggregateError([error, cleanupError], "TUI plugin reload and cleanup failed");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const retired = presentationFacets;
|
||||
presentationFacets = candidate;
|
||||
this.#presentationFacets = candidate;
|
||||
await retired.dispose();
|
||||
});
|
||||
this.#facetReloadTail = operation.catch(() => {});
|
||||
return operation;
|
||||
};
|
||||
const presentationBridgeFacet = defineFacet({
|
||||
id: "@pi/presentation-bridge",
|
||||
setup: (env) => {
|
||||
env.provide(PresentationUI, {
|
||||
select: (title, items, selectedValue) =>
|
||||
this.#select(
|
||||
title,
|
||||
items.map((item) => ({ ...item })),
|
||||
selectedValue,
|
||||
),
|
||||
showStatus: (status) => {
|
||||
this.#status = status;
|
||||
this.#rebuild();
|
||||
},
|
||||
});
|
||||
const commands = env.use(SlashCommands);
|
||||
const controller = env.use(AgentController);
|
||||
const transcript = env.use(Transcript);
|
||||
const sessionFeature: SessionFeature = {
|
||||
serverId: server.serverId,
|
||||
session: server.session,
|
||||
transcript,
|
||||
};
|
||||
env.onActivate(() => {
|
||||
if (this.#session !== undefined || this.#slashCommands !== undefined || this.#controller !== undefined) {
|
||||
throw new Error("Presentation services are already active");
|
||||
}
|
||||
this.#session = sessionFeature;
|
||||
this.#slashCommands = commands;
|
||||
this.#controller = controller;
|
||||
env.own(() => {
|
||||
if (this.#session === sessionFeature) this.#session = undefined;
|
||||
if (this.#slashCommands === commands) this.#slashCommands = undefined;
|
||||
if (this.#controller === controller) this.#controller = undefined;
|
||||
});
|
||||
env.own(commands.subscribe(() => this.#updateAutocomplete()));
|
||||
if (server.radius) {
|
||||
env.own(
|
||||
server.server.connection.subscribe((state) => this.#handleConnectionState(server.serverId, state)),
|
||||
);
|
||||
env.own(
|
||||
server.session.attachment.subscribe((state) => this.#handleAttachmentState(sessionFeature, state)),
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
facetHost = await createFacetHost({
|
||||
facets: [
|
||||
createSlashCommandsRuntimeFacet(),
|
||||
presentationBridgeFacet,
|
||||
createBuiltInSlashCommandsFacet({ reloadPresentationPlugins }),
|
||||
...this.#sharedFacets.facets,
|
||||
...presentationFacets.facets,
|
||||
],
|
||||
serviceSources: [server.server, server.session],
|
||||
});
|
||||
this.#facetHost = facetHost;
|
||||
}
|
||||
|
||||
async #openPreparedSession(prepared: PreparedClientSession): Promise<void> {
|
||||
const feature = this.#session;
|
||||
if (feature === undefined) throw new Error(`No Session service is available for ${prepared.server.serverId}`);
|
||||
await feature.session.whenAttached(prepared.summary.sessionId, BACKGROUND_CONTEXT);
|
||||
this.#selectedServerId = feature.serverId;
|
||||
this.#sessionId = prepared.summary.sessionId;
|
||||
this.#updateAutocomplete();
|
||||
await this.#openLane(feature);
|
||||
this.#screen = "chat";
|
||||
this.#status = "";
|
||||
this.#rebuild();
|
||||
}
|
||||
|
||||
async #close(): Promise<void> {
|
||||
this.#closed = true;
|
||||
this.#completeSelection(undefined);
|
||||
const errors: unknown[] = [];
|
||||
try {
|
||||
await this.#recoveryTransition;
|
||||
await this.#closeLane();
|
||||
await this.#facetReloadTail;
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
if (this.#facetHost !== undefined) {
|
||||
try {
|
||||
await this.#facetHost.dispose();
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
this.#facetHost = undefined;
|
||||
}
|
||||
const generations = [this.#presentationFacets, this.#sharedFacets].filter(
|
||||
(generation): generation is LoadedFacets => generation !== undefined,
|
||||
);
|
||||
this.#presentationFacets = undefined;
|
||||
const results = await Promise.allSettled(generations.map((generation) => generation.dispose()));
|
||||
errors.push(...results.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])));
|
||||
if (errors.length === 1) throw errors[0];
|
||||
if (errors.length > 1) throw new AggregateError(errors, "Failed to dispose experimental TUI facets");
|
||||
}
|
||||
|
||||
#rebuild(): void {
|
||||
this.#sessionHeading.setText(
|
||||
this.#sessionId === undefined || this.#selectedServerId === undefined
|
||||
? ""
|
||||
: theme.fg("dim", `Server: ${this.#selectedServerId}\nSession: ${this.#sessionId}`),
|
||||
);
|
||||
this.#statusContainer.clear();
|
||||
if (this.#status.length > 0) {
|
||||
this.#statusContainer.addChild(new Text(theme.fg("dim", this.#status), 1, 0));
|
||||
}
|
||||
if (this.#chatView !== undefined) this.#statusContainer.addChild(this.#chatView.status);
|
||||
this.#footerComponent.setText(theme.fg("dim", this.#footer()));
|
||||
this.#editorContainer.clear();
|
||||
if (this.#screen === "select" && this.#selection !== undefined) {
|
||||
this.#chatInput.focused = false;
|
||||
const selector = new Container();
|
||||
selector.addChild(new Text(theme.bold(this.#selection.title), 1, 1));
|
||||
const items = [...this.#selection.items];
|
||||
this.#selectList = new SelectList(items, Math.min(Math.max(items.length, 1), 12), selectTheme);
|
||||
const selectedIndex = items.findIndex((item) => item.value === this.#selection?.selectedValue);
|
||||
if (selectedIndex >= 0) this.#selectList.setSelectedIndex(selectedIndex);
|
||||
this.#selectList.onSelect = (item) => this.#completeSelection(item.value);
|
||||
this.#selectList.onCancel = () => this.#completeSelection(undefined);
|
||||
selector.addChild(this.#selectList);
|
||||
this.#editorContainer.addChild(selector);
|
||||
} else {
|
||||
this.#selectList = undefined;
|
||||
this.#chatInput.focused = !this.#busy;
|
||||
this.#editorContainer.addChild(this.#chatInput);
|
||||
}
|
||||
this.#layoutRoot.invalidate();
|
||||
this.#requestRender();
|
||||
}
|
||||
|
||||
#select(title: string, items: readonly SelectItem[], selectedValue?: string): Promise<string | undefined> {
|
||||
if (this.#selection !== undefined) throw new Error("A slash command selector is already active");
|
||||
return new Promise((resolve) => {
|
||||
this.#selection = { title, items, ...(selectedValue === undefined ? {} : { selectedValue }), resolve };
|
||||
this.#screen = "select";
|
||||
this.#rebuild();
|
||||
});
|
||||
}
|
||||
|
||||
#completeSelection(value: string | undefined): void {
|
||||
const selection = this.#selection;
|
||||
if (selection === undefined) return;
|
||||
this.#selection = undefined;
|
||||
this.#screen = "chat";
|
||||
selection.resolve(value);
|
||||
if (!this.#closed) this.#rebuild();
|
||||
}
|
||||
|
||||
#updateAutocomplete(): void {
|
||||
const commands = this.#selectedSlashCommands()?.list() ?? [];
|
||||
this.#chatInput.setAutocompleteProvider(
|
||||
new CombinedAutocompleteProvider(
|
||||
commands.map((command) => ({
|
||||
name: command.name,
|
||||
description: command.description,
|
||||
...(command.argumentHint === undefined ? {} : { argumentHint: command.argumentHint }),
|
||||
...(command.getArgumentCompletions === undefined
|
||||
? {}
|
||||
: {
|
||||
getArgumentCompletions: async (prefix: string) => {
|
||||
const items = await command.getArgumentCompletions!(prefix);
|
||||
return items === null ? null : [...items];
|
||||
},
|
||||
}),
|
||||
})),
|
||||
process.cwd(),
|
||||
),
|
||||
);
|
||||
this.#requestRender();
|
||||
}
|
||||
|
||||
#selectedSlashCommands(): SlashCommands | undefined {
|
||||
return this.#slashCommands;
|
||||
}
|
||||
|
||||
#handleConnectionState(serverId: string, state: ServerConnectionState): void {
|
||||
if (this.#closed || this.#selectedServerId !== serverId) return;
|
||||
if (state.status === "connected") {
|
||||
if (this.#laneUnsubscribe === undefined) {
|
||||
this.#busy = true;
|
||||
this.#status = "Reattaching Session…";
|
||||
this.#rebuild();
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.#busy = true;
|
||||
this.#status = state.status === "connecting" ? "Reconnecting to Radius…" : "Radius disconnected; retrying…";
|
||||
this.#queueRecovery(() => this.#closeLane());
|
||||
this.#rebuild();
|
||||
}
|
||||
|
||||
#handleAttachmentState(feature: SessionFeature, state: SessionAttachmentState): void {
|
||||
if (this.#closed || this.#selectedServerId !== feature.serverId || this.#sessionId === undefined) return;
|
||||
if (state.status === "attached" && state.sessionId === this.#sessionId) {
|
||||
this.#queueRecovery(async () => {
|
||||
if (this.#laneUnsubscribe === undefined) await this.#openLane(feature);
|
||||
this.#busy = false;
|
||||
this.#status = "";
|
||||
this.#rebuild();
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (state.status === "attaching" && state.sessionId === this.#sessionId) {
|
||||
this.#busy = true;
|
||||
this.#status = "Reattaching Session…";
|
||||
this.#rebuild();
|
||||
}
|
||||
}
|
||||
|
||||
#queueRecovery(operation: () => Promise<void>): void {
|
||||
this.#recoveryTransition = this.#recoveryTransition
|
||||
.then(async () => {
|
||||
if (!this.#closed) await operation();
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (this.#closed) return;
|
||||
this.#busy = true;
|
||||
this.#status = `Reconnect error: ${message(error)}`;
|
||||
this.#rebuild();
|
||||
});
|
||||
}
|
||||
|
||||
async #openLane(feature: SessionFeature): Promise<void> {
|
||||
await this.#closeLane();
|
||||
const view = new ExperimentalChatView(this.#ui, process.cwd());
|
||||
this.#chatView = view;
|
||||
this.#documentContainer.addChild(this.#sessionHeading);
|
||||
this.#documentContainer.addChild(view.transcript);
|
||||
this.#pendingMessagesContainer.addChild(view.pendingMessages);
|
||||
this.#laneUnsubscribe = feature.transcript.state.subscribe((value) => {
|
||||
if (value.snapshot === null) return;
|
||||
view.apply(value.snapshot);
|
||||
this.#rebuild();
|
||||
});
|
||||
if (feature.transcript.state.value?.snapshot === null || feature.transcript.state.value?.snapshot === undefined) {
|
||||
await this.#closeLane();
|
||||
throw new Error("Transcript has no initialized snapshot");
|
||||
}
|
||||
}
|
||||
|
||||
async #closeLane(): Promise<void> {
|
||||
this.#laneUnsubscribe?.();
|
||||
this.#laneUnsubscribe = undefined;
|
||||
this.#chatView?.dispose();
|
||||
this.#chatView = undefined;
|
||||
this.#documentContainer.clear();
|
||||
this.#pendingMessagesContainer.clear();
|
||||
this.#statusContainer.clear();
|
||||
}
|
||||
|
||||
async #runPrompt(messageText: string): Promise<void> {
|
||||
const prompt = messageText.trim();
|
||||
if (prompt.length === 0) return;
|
||||
if (prompt.startsWith("/")) {
|
||||
const separator = prompt.indexOf(" ");
|
||||
const name = prompt.slice(1, separator === -1 ? undefined : separator);
|
||||
const args = separator === -1 ? "" : prompt.slice(separator + 1).trim();
|
||||
await this.#executeSlashCommand(name, args);
|
||||
return;
|
||||
}
|
||||
this.#chatInput.setText("");
|
||||
try {
|
||||
await this.#submitPrompt(prompt);
|
||||
} catch (error) {
|
||||
this.#status = `Error: ${message(error)}`;
|
||||
this.#rebuild();
|
||||
}
|
||||
}
|
||||
|
||||
async #executeSlashCommand(name: string, args: string): Promise<void> {
|
||||
const command = this.#selectedSlashCommands()
|
||||
?.list()
|
||||
.find((candidate) => candidate.name === name);
|
||||
this.#chatInput.setText("");
|
||||
if (command === undefined) {
|
||||
this.#status = `Unknown slash command: /${name}`;
|
||||
this.#rebuild();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await command.run(args, BACKGROUND_CONTEXT);
|
||||
if (result !== undefined) {
|
||||
if ("entryId" in result) this.#reportQueue(result);
|
||||
else this.#reportOperation(result);
|
||||
}
|
||||
} catch (error) {
|
||||
this.#status = `Error: ${message(error)}`;
|
||||
this.#rebuild();
|
||||
}
|
||||
}
|
||||
|
||||
async #submitPrompt(prompt: string): Promise<void> {
|
||||
const controller = this.#selectedController();
|
||||
if (controller === undefined) throw new Error("No Session AgentController service is available");
|
||||
const operation = this.#laneSnapshot()?.operation;
|
||||
const running = operation !== null && operation !== undefined;
|
||||
this.#status = running ? "Queueing steering message…" : "Running turn…";
|
||||
this.#rebuild();
|
||||
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));
|
||||
}
|
||||
|
||||
async #queueFollowUp(text: string): Promise<void> {
|
||||
const controller = this.#selectedController();
|
||||
if (controller === undefined) return;
|
||||
try {
|
||||
this.#status = "Queueing follow-up…";
|
||||
this.#rebuild();
|
||||
this.#reportQueue(await controller.followUp({ message: text, images: null }, BACKGROUND_CONTEXT));
|
||||
} catch (error) {
|
||||
this.#status = `Error: ${message(error)}`;
|
||||
this.#rebuild();
|
||||
}
|
||||
}
|
||||
|
||||
#reportOperation(response: AgentOperationResponse): void {
|
||||
this.#status = response.accepted
|
||||
? response.error === null
|
||||
? ""
|
||||
: `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.#laneSnapshot()?.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();
|
||||
});
|
||||
}
|
||||
|
||||
#selectedController(): AgentController | undefined {
|
||||
return this.#controller;
|
||||
}
|
||||
|
||||
#laneSnapshot() {
|
||||
const snapshot = this.#session?.transcript.state.value?.snapshot;
|
||||
return snapshot === null ? undefined : snapshot;
|
||||
}
|
||||
|
||||
#footer(): string {
|
||||
const snapshot = this.#laneSnapshot();
|
||||
if (!snapshot) return "/model · /thinking · /compact · /reload";
|
||||
return `${snapshot.configuration.model.provider}/${snapshot.configuration.model.modelId} · thinking:${snapshot.configuration.thinkingLevel} · ${snapshot.stats.messageCount} messages · /model · /thinking · /compact · /reload`;
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareClientSession(
|
||||
command: ClientCommand,
|
||||
servers: readonly ClientTuiServer[],
|
||||
): Promise<PreparedClientSession> {
|
||||
const opened = servers.map((server) => ({
|
||||
server,
|
||||
services: server.server.open({
|
||||
services: [SessionDirectory, SessionManagement, PresentationPlugins],
|
||||
assertAccess() {},
|
||||
onError() {},
|
||||
}),
|
||||
}));
|
||||
try {
|
||||
const features = opened.map(({ server, services }) => ({
|
||||
server,
|
||||
directory: services.use(SessionDirectory),
|
||||
management: services.use(SessionManagement),
|
||||
plugins: services.use(PresentationPlugins),
|
||||
}));
|
||||
await Promise.all(opened.map(({ services }) => services.ready(BACKGROUND_CONTEXT)));
|
||||
let selected:
|
||||
| {
|
||||
readonly server: ClientTuiServer;
|
||||
readonly management: SessionManagement;
|
||||
readonly plugins: PresentationPlugins;
|
||||
readonly 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) => ({
|
||||
server: feature.server,
|
||||
management: feature.management,
|
||||
plugins: feature.plugins,
|
||||
summary,
|
||||
})),
|
||||
);
|
||||
if (matches.length > 1) throw new Error(`Session ${command.sessionId} is available from more than one server`);
|
||||
selected = matches[0];
|
||||
if (selected === undefined) {
|
||||
if (command.connect?.transport === "radius") {
|
||||
throw new Error(`Remote server does not contain Session ${command.sessionId}`);
|
||||
}
|
||||
const feature = requireSingleServer(features);
|
||||
selected = {
|
||||
server: feature.server,
|
||||
management: feature.management,
|
||||
plugins: feature.plugins,
|
||||
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) => ({
|
||||
server: feature.server,
|
||||
management: feature.management,
|
||||
plugins: feature.plugins,
|
||||
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 = {
|
||||
server: feature.server,
|
||||
management: feature.management,
|
||||
plugins: feature.plugins,
|
||||
summary: await feature.management.create({}, BACKGROUND_CONTEXT),
|
||||
};
|
||||
}
|
||||
const presentationPlugins = await selected.plugins.prepareSession(
|
||||
{
|
||||
sessionId: selected.summary.sessionId,
|
||||
packagePaths: command.pluginPackages?.map((packagePath) => resolve(packagePath)) ?? null,
|
||||
},
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
await selected.management.attach(selected.summary.sessionId, BACKGROUND_CONTEXT);
|
||||
await selected.server.session.whenAttached(selected.summary.sessionId, BACKGROUND_CONTEXT);
|
||||
return {
|
||||
server: selected.server,
|
||||
summary: selected.summary,
|
||||
presentationPlugins,
|
||||
};
|
||||
} finally {
|
||||
await Promise.allSettled(opened.map(({ services }) => services.dispose(BACKGROUND_CONTEXT)));
|
||||
}
|
||||
}
|
||||
|
||||
export async function runClientTui(command: ClientCommand, options: RunClientTuiOptions = {}): Promise<void> {
|
||||
const cwd = process.cwd();
|
||||
const agentDir = getAgentDir();
|
||||
const settingsManager = SettingsManager.create(cwd, agentDir);
|
||||
const resourceLoader = new DefaultResourceLoader({
|
||||
cwd,
|
||||
agentDir,
|
||||
settingsManager,
|
||||
noExtensions: true,
|
||||
noSkills: true,
|
||||
noPromptTemplates: true,
|
||||
noContextFiles: true,
|
||||
});
|
||||
await resourceLoader.reload();
|
||||
setRegisteredThemes(resourceLoader.getThemes().themes);
|
||||
const runtime = await openClientRuntime(command, options);
|
||||
const tui = createInteractiveTui({
|
||||
tuiMode: "fullscreen",
|
||||
showHardwareCursor: settingsManager.getShowHardwareCursor(),
|
||||
logDirectory: agentDir,
|
||||
});
|
||||
tui.setClearOnShrink(settingsManager.getClearOnShrink());
|
||||
let component: ExperimentalClientTui | undefined;
|
||||
let tuiStarted = false;
|
||||
const themeController = new InteractiveThemeController(tui, {
|
||||
getSettingsManager: () => settingsManager,
|
||||
showError: (error) => component?.showError(error),
|
||||
onChanged: () => component?.refreshTheme(),
|
||||
});
|
||||
try {
|
||||
let finish!: () => void;
|
||||
const finished = new Promise<void>((resolve) => {
|
||||
finish = () => {
|
||||
themeController.disableAutoSync();
|
||||
if (tuiStarted) {
|
||||
tui.stop();
|
||||
tuiStarted = false;
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
component = await ExperimentalClientTui.create({
|
||||
command,
|
||||
ui: tui,
|
||||
servers: runtime.servers.map((server) => ({
|
||||
serverId: server.route.serverId,
|
||||
radius: server.route.transport === "radius",
|
||||
server: server.server,
|
||||
session: server.session,
|
||||
})),
|
||||
facetLoader: options.facetLoader,
|
||||
requestRender: () => tui.requestRender(),
|
||||
finish,
|
||||
});
|
||||
tui.addChild(component);
|
||||
tui.setLayoutRoot(component.layoutRoot);
|
||||
tui.setFocus(component);
|
||||
tuiStarted = true;
|
||||
tui.start();
|
||||
await themeController.applyFromSettings();
|
||||
await finished;
|
||||
} finally {
|
||||
themeController.dispose();
|
||||
stopThemeWatcher();
|
||||
if (tuiStarted) tui.stop();
|
||||
await component?.close();
|
||||
await runtime.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
function requireSingleServer<T>(features: readonly T[]): T {
|
||||
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);
|
||||
}
|
||||
124
third_party/pi/packages/coding-agent/src/experimental/client.ts
vendored
Normal file
124
third_party/pi/packages/coding-agent/src/experimental/client.ts
vendored
Normal file
@@ -0,0 +1,124 @@
|
||||
import { resolve } from "node:path";
|
||||
import { BACKGROUND_CONTEXT, type LaneWatchEvent } from "@earendil-works/pi-agent-core";
|
||||
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
||||
import type { ClientCommand } from "../cli/experimental/commands/client.ts";
|
||||
import { activateBuiltinClientServices, openClientRuntime } from "./client-runtime.ts";
|
||||
import type { AgentOperationResponse } from "./services/agent-controller.ts";
|
||||
import type { SessionAddress } from "./services/sessions.ts";
|
||||
|
||||
export type ClientResult =
|
||||
| {
|
||||
readonly kind: "list";
|
||||
readonly sessions: readonly SessionAddress[];
|
||||
}
|
||||
| { readonly kind: "attached"; readonly serverId: string; readonly sessionId: string }
|
||||
| { readonly kind: "prompted"; readonly serverId: string; readonly sessionId: string; readonly text: string };
|
||||
|
||||
export interface RunClientOptions {
|
||||
/** Directory searched when --connect is omitted. Defaults to PI_SERVER_DIR or ~/.pi/server. */
|
||||
readonly directory?: string;
|
||||
/** Receives snapshot-ordered main-lane events while a prompt is active. */
|
||||
readonly onEvent?: (event: LaneWatchEvent) => void | Promise<void>;
|
||||
}
|
||||
|
||||
/** Discover servers, then list Sessions, attach to one, or create one for a prompt. */
|
||||
export async function runClient(command: ClientCommand, options: RunClientOptions = {}): Promise<ClientResult> {
|
||||
const runtime = await openClientRuntime(command, { directory: options.directory });
|
||||
try {
|
||||
const discovered = await Promise.all(runtime.servers.map(activateBuiltinClientServices));
|
||||
let sessionId = command.sessionId;
|
||||
if (sessionId === undefined && command.prompt === undefined) {
|
||||
return {
|
||||
kind: "list",
|
||||
sessions: discovered
|
||||
.flatMap(({ route, directory }) =>
|
||||
directory.state.value!.sessions.map(({ sessionId }) => ({ serverId: route.serverId, sessionId })),
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.serverId.localeCompare(right.serverId) || left.sessionId.localeCompare(right.sessionId),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
let match: (typeof discovered)[number];
|
||||
if (sessionId === undefined) {
|
||||
if (discovered.length !== 1) {
|
||||
throw new Error("Client prompt requires exactly one discovered server to create a Session");
|
||||
}
|
||||
match = discovered[0]!;
|
||||
sessionId = (await match.management.create({}, BACKGROUND_CONTEXT)).sessionId;
|
||||
} else {
|
||||
const selectedSessionId = sessionId;
|
||||
const matches = discovered.filter((candidate) =>
|
||||
candidate.directory.state.value!.sessions.some(({ sessionId }) => sessionId === selectedSessionId),
|
||||
);
|
||||
if (matches.length > 1) {
|
||||
throw new Error(`Session ${selectedSessionId} is available from more than one server`);
|
||||
}
|
||||
const existing = matches[0];
|
||||
if (existing) {
|
||||
match = existing;
|
||||
} else {
|
||||
if (command.connect?.transport === "radius" || command.prompt === undefined || discovered.length !== 1) {
|
||||
throw new Error(`No discovered server contains session ${selectedSessionId}`);
|
||||
}
|
||||
match = discovered[0]!;
|
||||
await match.management.create({ id: selectedSessionId }, BACKGROUND_CONTEXT);
|
||||
}
|
||||
}
|
||||
await match.plugins.prepareSession(
|
||||
{
|
||||
sessionId,
|
||||
packagePaths: command.pluginPackages?.map((packagePath) => resolve(packagePath)) ?? null,
|
||||
},
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
await match.management.attach(sessionId, BACKGROUND_CONTEXT);
|
||||
if (command.prompt === undefined) {
|
||||
return { kind: "attached", serverId: match.route.serverId, sessionId };
|
||||
}
|
||||
|
||||
const agent = match.agent;
|
||||
const completedText = new Map<string, string>();
|
||||
let deliveryTail = Promise.resolve();
|
||||
const unsubscribe = match.transcript.state.subscribe((value, _context, delivery) => {
|
||||
if (delivery.kind !== "update" || value.event === null) return;
|
||||
const event = value.event;
|
||||
deliveryTail = deliveryTail.then(async () => {
|
||||
if (event.type === "message_end" && event.runId !== undefined && event.message.role === "assistant") {
|
||||
completedText.set(event.runId, messageText(event.message));
|
||||
}
|
||||
await options.onEvent?.(event);
|
||||
});
|
||||
});
|
||||
if (match.transcript.state.value?.snapshot === null || match.transcript.state.value?.snapshot === undefined) {
|
||||
unsubscribe();
|
||||
throw new Error("Transcript has no initialized snapshot");
|
||||
}
|
||||
let response: AgentOperationResponse;
|
||||
try {
|
||||
response = await agent.prompt({ message: command.prompt, images: null }, BACKGROUND_CONTEXT);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
await deliveryTail;
|
||||
}
|
||||
if (!response.accepted) throw new Error(response.error.message);
|
||||
if (response.error !== null) throw new Error(response.error.message);
|
||||
return {
|
||||
kind: "prompted",
|
||||
serverId: match.route.serverId,
|
||||
sessionId,
|
||||
text: completedText.get(response.operationId) ?? "",
|
||||
};
|
||||
} finally {
|
||||
await runtime.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
function messageText(message: AssistantMessage): string {
|
||||
return message.content
|
||||
.filter((content) => content.type === "text")
|
||||
.map((content) => content.text)
|
||||
.join("");
|
||||
}
|
||||
106
third_party/pi/packages/coding-agent/src/experimental/commands.ts
vendored
Normal file
106
third_party/pi/packages/coding-agent/src/experimental/commands.ts
vendored
Normal file
@@ -0,0 +1,106 @@
|
||||
import chalk from "chalk";
|
||||
import { cli } from "../cli/experimental/cli.ts";
|
||||
import type { ClientCommand } from "../cli/experimental/commands/client.ts";
|
||||
import type { ServerCommand } from "../cli/experimental/commands/server.ts";
|
||||
import { areExperimentalFeaturesEnabled } from "../core/experimental.ts";
|
||||
import { runClient } from "./client.ts";
|
||||
import { runClientTui } from "./client-tui.ts";
|
||||
import type { RadiusRelayHostStatus } from "./radius-relay.ts";
|
||||
import { startForegroundServer } from "./server.ts";
|
||||
|
||||
async function runServerCommand(command: ServerCommand): Promise<void> {
|
||||
let previousRelayStatus = "";
|
||||
let relayOutputReady = false;
|
||||
let pendingRelayStatus: RadiusRelayHostStatus | undefined;
|
||||
const reportRelayStatus = (status: RadiusRelayHostStatus): void => {
|
||||
const description =
|
||||
status.status === "connected"
|
||||
? "connected"
|
||||
: status.status === "not_authenticated"
|
||||
? "not connected; local only"
|
||||
: status.status === "retrying"
|
||||
? `reconnecting: ${status.error}`
|
||||
: "connecting";
|
||||
if (description === previousRelayStatus || status.status === "connecting") return;
|
||||
previousRelayStatus = description;
|
||||
console.log(`Radius: ${description}`);
|
||||
};
|
||||
const runtime = await startForegroundServer({
|
||||
serverId: command.serverId,
|
||||
sessionDir: command.sessionDir,
|
||||
provider: command.provider,
|
||||
model: command.model,
|
||||
pluginPackages: command.pluginPackages ?? [],
|
||||
relayAuth: command.auth,
|
||||
onRelayStatus(status) {
|
||||
if (relayOutputReady) reportRelayStatus(status);
|
||||
else pendingRelayStatus = status;
|
||||
},
|
||||
});
|
||||
console.log(`Server: ${runtime.serverId}`);
|
||||
console.log(`Socket: ${runtime.socketPath}`);
|
||||
relayOutputReady = true;
|
||||
if (pendingRelayStatus !== undefined) reportRelayStatus(pendingRelayStatus);
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const cleanup = (): void => {
|
||||
process.off("SIGINT", finish);
|
||||
process.off("SIGTERM", finish);
|
||||
};
|
||||
const finish = (): void => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const fail = (error: unknown): void => {
|
||||
cleanup();
|
||||
reject(error);
|
||||
};
|
||||
process.once("SIGINT", finish);
|
||||
process.once("SIGTERM", finish);
|
||||
void runtime.closed.then(finish, fail);
|
||||
});
|
||||
} finally {
|
||||
await runtime.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function runClientCommand(command: ClientCommand): Promise<void> {
|
||||
if (command.prompt === undefined && process.stdin.isTTY === true && process.stdout.isTTY === true) {
|
||||
await runClientTui(command);
|
||||
return;
|
||||
}
|
||||
let streamedText = false;
|
||||
const result = await runClient(command, {
|
||||
onEvent(event) {
|
||||
if (event.type !== "message_update" || event.frame?.type !== "text_delta") return;
|
||||
streamedText = true;
|
||||
process.stdout.write(event.frame.delta);
|
||||
},
|
||||
});
|
||||
if (result.kind === "attached") {
|
||||
console.log(`${result.serverId}\t${result.sessionId}\tattached`);
|
||||
return;
|
||||
}
|
||||
if (result.kind === "prompted") {
|
||||
if (streamedText) process.stdout.write("\n");
|
||||
else console.log(result.text);
|
||||
return;
|
||||
}
|
||||
for (const session of result.sessions) console.log(`${session.serverId}\t${session.sessionId}`);
|
||||
}
|
||||
|
||||
/** Development-only command dispatch. Published entrypoints must not import this module. */
|
||||
export async function runExperimentalCommand(args: string[]): Promise<boolean> {
|
||||
if (!areExperimentalFeaturesEnabled() || (args[0] !== "server" && args[0] !== "client")) return false;
|
||||
try {
|
||||
const result = await cli.execute(args, { runServer: runServerCommand, runClient: runClientCommand });
|
||||
if (!result.ok) {
|
||||
for (const error of result.errors) console.error(chalk.red(`Error: ${error}`));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(chalk.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
10
third_party/pi/packages/coding-agent/src/experimental/coordinator-entry.ts
vendored
Normal file
10
third_party/pi/packages/coding-agent/src/experimental/coordinator-entry.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env node
|
||||
import { runCoordinatorProcess } from "./coordinator.ts";
|
||||
import { consumeInternalProcessRole } from "./process.ts";
|
||||
|
||||
const role = consumeInternalProcessRole();
|
||||
if (role !== "coordinator") throw new Error("Coordinator entrypoint requires an internal coordinator invocation");
|
||||
void runCoordinatorProcess(process.argv.slice(2)).catch((error: unknown) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
612
third_party/pi/packages/coding-agent/src/experimental/coordinator.ts
vendored
Normal file
612
third_party/pi/packages/coding-agent/src/experimental/coordinator.ts
vendored
Normal file
@@ -0,0 +1,612 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { chmod, lstat, unlink } from "node:fs/promises";
|
||||
import { createConnection, createServer, type Server, type Socket } from "node:net";
|
||||
import Type, { type Static } from "typebox";
|
||||
import { Check } from "typebox/value";
|
||||
import {
|
||||
consumeInternalProcessRole,
|
||||
encodeControlLine,
|
||||
isDirectInternalProcessEntry,
|
||||
MAX_CONTROL_LINE_BYTES,
|
||||
spawnInternalProcess,
|
||||
} from "./process.ts";
|
||||
|
||||
export const COORDINATOR_PROTOCOL_VERSION = 3;
|
||||
const COORDINATOR_START_TIMEOUT_MS = 10_000;
|
||||
const COORDINATOR_RETRY_MS = 10;
|
||||
|
||||
const CoordinatorMessageSchema = Type.Union([
|
||||
Type.Object({
|
||||
type: Type.Literal("server_registered"),
|
||||
serverConnectionId: Type.String(),
|
||||
peers: Type.Array(Type.String()),
|
||||
}),
|
||||
Type.Object({ type: Type.Literal("server_replaced") }),
|
||||
Type.Object({ type: Type.Literal("peer_connected"), peerId: Type.String() }),
|
||||
Type.Object({ type: Type.Literal("peer_disconnected"), peerId: Type.String() }),
|
||||
Type.Object({ type: Type.Literal("message"), from: Type.String(), payload: Type.Unknown() }),
|
||||
]);
|
||||
type CoordinatorMessage = Static<typeof CoordinatorMessageSchema>;
|
||||
|
||||
export type CoordinatorConnectionEvent =
|
||||
| { readonly type: "peer_connected"; readonly peerId: string }
|
||||
| { readonly type: "peer_disconnected"; readonly peerId: string }
|
||||
| { readonly type: "message"; readonly from: string; readonly payload: unknown };
|
||||
|
||||
export interface CoordinatorConnectionOptions {
|
||||
readonly controlPath: string;
|
||||
readonly endpoint: string;
|
||||
readonly serverConnectionId?: string;
|
||||
}
|
||||
|
||||
/** The server-side endpoint of the coordinator's intentionally opaque message router. */
|
||||
export class CoordinatorConnection {
|
||||
readonly serverConnectionId: string;
|
||||
readonly replaced: Promise<void>;
|
||||
readonly peerIds = new Set<string>();
|
||||
readonly #controlPath: string;
|
||||
readonly #endpoint: string;
|
||||
readonly #listeners = new Set<(event: CoordinatorConnectionEvent) => void>();
|
||||
#socket?: Socket;
|
||||
#registered = false;
|
||||
#closed = false;
|
||||
#replacedValue = false;
|
||||
#resolveRegistered?: () => void;
|
||||
#rejectRegistered?: (error: Error) => void;
|
||||
#resolveReplaced!: () => void;
|
||||
|
||||
constructor(options: CoordinatorConnectionOptions) {
|
||||
this.serverConnectionId = options.serverConnectionId ?? randomUUID();
|
||||
this.#controlPath = options.controlPath;
|
||||
this.#endpoint = options.endpoint;
|
||||
this.replaced = new Promise((resolve) => {
|
||||
this.#resolveReplaced = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
get controlPath(): string {
|
||||
return this.#controlPath;
|
||||
}
|
||||
|
||||
get wasReplaced(): boolean {
|
||||
return this.#replacedValue;
|
||||
}
|
||||
|
||||
onEvent(listener: (event: CoordinatorConnectionEvent) => void): () => void {
|
||||
this.#listeners.add(listener);
|
||||
return () => this.#listeners.delete(listener);
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (this.#socket) throw new Error("Coordinator server is already connected");
|
||||
const socket = await connectSocket(this.#controlPath);
|
||||
this.#socket = socket;
|
||||
attachJsonLineReader(socket, (message) => this.#handleMessage(message));
|
||||
const registered = new Promise<void>((resolve, reject) => {
|
||||
this.#resolveRegistered = resolve;
|
||||
this.#rejectRegistered = reject;
|
||||
});
|
||||
socket.once("close", () => this.#disconnected(new Error("Coordinator connection closed")));
|
||||
socket.once("error", (error) => this.#disconnected(error));
|
||||
await writeJsonLine(socket, {
|
||||
type: "register_server",
|
||||
protocol: COORDINATOR_PROTOCOL_VERSION,
|
||||
serverConnectionId: this.serverConnectionId,
|
||||
endpoint: this.#endpoint,
|
||||
});
|
||||
await registered;
|
||||
}
|
||||
|
||||
send(peerId: string, payload: unknown): Promise<void> {
|
||||
return this.#write({ type: "send", to: peerId, payload });
|
||||
}
|
||||
|
||||
broadcast(payload: unknown): Promise<void> {
|
||||
return this.#write({ type: "broadcast", payload });
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.#closed) return;
|
||||
this.#closed = true;
|
||||
this.#socket?.destroy();
|
||||
this.#socket = undefined;
|
||||
this.peerIds.clear();
|
||||
this.#rejectRegistered?.(new Error("Coordinator server closed"));
|
||||
this.#resolveRegistered = undefined;
|
||||
this.#rejectRegistered = undefined;
|
||||
}
|
||||
|
||||
async #write(message: unknown): Promise<void> {
|
||||
if (!this.#registered || !this.#socket || this.#closed) throw new Error("Coordinator server is not connected");
|
||||
await writeJsonLine(this.#socket, message);
|
||||
}
|
||||
|
||||
#handleMessage(value: unknown): void {
|
||||
if (!Check(CoordinatorMessageSchema, value)) {
|
||||
this.#socket?.destroy(new Error("Coordinator sent an invalid message"));
|
||||
return;
|
||||
}
|
||||
const message: CoordinatorMessage = value;
|
||||
if (message.type === "server_registered") {
|
||||
if (message.serverConnectionId !== this.serverConnectionId) {
|
||||
this.#socket?.destroy(new Error("Coordinator returned an invalid server registration"));
|
||||
return;
|
||||
}
|
||||
for (const peerId of message.peers) this.peerIds.add(peerId);
|
||||
this.#registered = true;
|
||||
this.#resolveRegistered?.();
|
||||
this.#resolveRegistered = undefined;
|
||||
this.#rejectRegistered = undefined;
|
||||
return;
|
||||
}
|
||||
if (message.type === "server_replaced") {
|
||||
this.#markReplaced();
|
||||
return;
|
||||
}
|
||||
if (message.type === "peer_connected") {
|
||||
this.peerIds.add(message.peerId);
|
||||
this.#emit({ type: "peer_connected", peerId: message.peerId });
|
||||
return;
|
||||
}
|
||||
if (message.type === "peer_disconnected") {
|
||||
this.peerIds.delete(message.peerId);
|
||||
this.#emit({ type: "peer_disconnected", peerId: message.peerId });
|
||||
return;
|
||||
}
|
||||
if (message.type === "message") {
|
||||
this.#emit({ type: "message", from: message.from, payload: message.payload });
|
||||
return;
|
||||
}
|
||||
this.#socket?.destroy(new Error("Coordinator sent an unsupported message"));
|
||||
}
|
||||
|
||||
#emit(event: CoordinatorConnectionEvent): void {
|
||||
for (const listener of this.#listeners) listener(event);
|
||||
}
|
||||
|
||||
#disconnected(error: Error): void {
|
||||
this.#socket = undefined;
|
||||
if (this.#closed) return;
|
||||
this.#rejectRegistered?.(error);
|
||||
this.#resolveRegistered = undefined;
|
||||
this.#rejectRegistered = undefined;
|
||||
this.#markReplaced();
|
||||
}
|
||||
|
||||
#markReplaced(): void {
|
||||
if (this.#replacedValue) return;
|
||||
this.#replacedValue = true;
|
||||
this.#resolveReplaced();
|
||||
}
|
||||
}
|
||||
|
||||
export interface CoordinatorStartupLease {
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export async function ensureCoordinator(publicPath: string, controlPath: string): Promise<CoordinatorStartupLease> {
|
||||
const existing = await tryConnect(controlPath);
|
||||
if (existing) return { close: () => existing.destroy() };
|
||||
const child = spawnInternalProcess("coordinator", [publicPath, controlPath]);
|
||||
const deadline = Date.now() + COORDINATOR_START_TIMEOUT_MS;
|
||||
while (true) {
|
||||
const socket = await tryConnect(controlPath);
|
||||
if (socket) return { close: () => socket.destroy() };
|
||||
if (child.exitCode !== null || child.signalCode !== null) throw new Error("Coordinator exited during startup");
|
||||
if (Date.now() >= deadline) throw new Error("Timed out waiting for coordinator startup");
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, COORDINATOR_RETRY_MS));
|
||||
}
|
||||
}
|
||||
|
||||
function connectSocket(path: string): Promise<Socket> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = createConnection(path);
|
||||
const onConnect = (): void => {
|
||||
socket.off("error", onError);
|
||||
resolve(socket);
|
||||
};
|
||||
const onError = (error: Error): void => {
|
||||
socket.off("connect", onConnect);
|
||||
reject(error);
|
||||
};
|
||||
socket.once("connect", onConnect);
|
||||
socket.once("error", onError);
|
||||
});
|
||||
}
|
||||
|
||||
async function tryConnect(path: string): Promise<Socket | undefined> {
|
||||
try {
|
||||
return await connectSocket(path);
|
||||
} catch (error) {
|
||||
const code = error instanceof Error && "code" in error ? error.code : undefined;
|
||||
if (code === "ENOENT" || code === "ECONNREFUSED") return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function attachJsonLineReader(socket: Socket, onMessage: (message: unknown) => void): void {
|
||||
let buffered = "";
|
||||
socket.setEncoding("utf8");
|
||||
socket.on("data", (chunk: string) => {
|
||||
buffered += chunk;
|
||||
if (Buffer.byteLength(buffered) > MAX_CONTROL_LINE_BYTES) {
|
||||
socket.destroy(new Error("Coordinator message is too large"));
|
||||
return;
|
||||
}
|
||||
while (true) {
|
||||
const newline = buffered.indexOf("\n");
|
||||
if (newline === -1) return;
|
||||
const line = buffered.slice(0, newline);
|
||||
buffered = buffered.slice(newline + 1);
|
||||
try {
|
||||
onMessage(JSON.parse(line));
|
||||
} catch {
|
||||
socket.destroy(new Error("Coordinator sent invalid JSON"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function writeJsonLine(socket: Socket, message: unknown): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
socket.write(encodeControlLine(message), (error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// This process is intentionally a transport shim. It depends only on Node
|
||||
// built-ins and never interprets Pi, session, worker, or lifecycle payloads.
|
||||
|
||||
const EMPTY_STARTUP_GRACE_MS = 30_000;
|
||||
const EMPTY_SHUTDOWN_GRACE_MS = 250;
|
||||
|
||||
interface ServerPeer {
|
||||
readonly serverConnectionId: string;
|
||||
readonly endpoint: string;
|
||||
readonly socket: Socket;
|
||||
}
|
||||
|
||||
interface RoutedPeer {
|
||||
readonly peerId: string;
|
||||
readonly socket: Socket;
|
||||
}
|
||||
|
||||
interface ControlMessage {
|
||||
readonly type: string;
|
||||
readonly protocol?: unknown;
|
||||
readonly serverConnectionId?: unknown;
|
||||
readonly endpoint?: unknown;
|
||||
readonly peerId?: unknown;
|
||||
readonly to?: unknown;
|
||||
readonly payload?: unknown;
|
||||
}
|
||||
|
||||
let publicPath: string;
|
||||
let controlPath: string;
|
||||
let running = false;
|
||||
|
||||
const peers = new Map<string, RoutedPeer>();
|
||||
const controlConnections = new Set<Socket>();
|
||||
const publicConnections = new Map<Socket, Socket>();
|
||||
let currentServer: ServerPeer | undefined;
|
||||
let shuttingDown = false;
|
||||
let emptyTimer: NodeJS.Timeout | undefined;
|
||||
|
||||
const publicServer = createServer((socket) => acceptPublicConnection(socket));
|
||||
const controlServer = createServer((socket) => acceptControlConnection(socket));
|
||||
|
||||
export async function runCoordinatorProcess(args: readonly string[]): Promise<void> {
|
||||
if (running) throw new Error("Coordinator process is already running");
|
||||
const [requestedPublicPath, requestedControlPath] = args;
|
||||
if (!requestedPublicPath || !requestedControlPath) {
|
||||
throw new Error("Coordinator requires public and control socket paths");
|
||||
}
|
||||
publicPath = requestedPublicPath;
|
||||
controlPath = requestedControlPath;
|
||||
running = true;
|
||||
process.once("SIGINT", () => void shutdownCoordinator());
|
||||
process.once("SIGTERM", () => void shutdownCoordinator());
|
||||
await main();
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await removeStaleSocket(controlPath);
|
||||
await removeStaleSocket(publicPath);
|
||||
try {
|
||||
await listen(controlServer, controlPath);
|
||||
await restrictSocket(controlPath);
|
||||
await listen(publicServer, publicPath);
|
||||
await restrictSocket(publicPath);
|
||||
} catch (error) {
|
||||
await Promise.all([cleanupSocket(controlPath), cleanupSocket(publicPath)]);
|
||||
throw error;
|
||||
}
|
||||
scheduleEmptyShutdown(EMPTY_STARTUP_GRACE_MS);
|
||||
}
|
||||
|
||||
function acceptControlConnection(socket: Socket): void {
|
||||
if (shuttingDown) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
controlConnections.add(socket);
|
||||
let server: ServerPeer | undefined;
|
||||
let peer: RoutedPeer | undefined;
|
||||
attachRoutedLineReader(socket, (message) => {
|
||||
if (!server && !peer) {
|
||||
if (message.type === "register_server") {
|
||||
server = registerServer(socket, message);
|
||||
return;
|
||||
}
|
||||
if (message.type === "register_peer") {
|
||||
peer = registerPeer(socket, message);
|
||||
return;
|
||||
}
|
||||
throw new Error("Coordinator connection did not register a role");
|
||||
}
|
||||
if (server) {
|
||||
if (server === currentServer) handleRoutedMessage("server", message);
|
||||
return;
|
||||
}
|
||||
handleRoutedMessage(peer!.peerId, message);
|
||||
});
|
||||
const disconnect = (): void => {
|
||||
controlConnections.delete(socket);
|
||||
if (server && currentServer === server) {
|
||||
currentServer = undefined;
|
||||
notifyPeers({ type: "server_disconnected", serverConnectionId: server.serverConnectionId });
|
||||
}
|
||||
if (peer && peers.get(peer.peerId) === peer) {
|
||||
peers.delete(peer.peerId);
|
||||
if (currentServer) writeRoutedLine(currentServer.socket, { type: "peer_disconnected", peerId: peer.peerId });
|
||||
}
|
||||
checkEmpty();
|
||||
};
|
||||
socket.once("close", disconnect);
|
||||
socket.once("error", () => socket.destroy());
|
||||
}
|
||||
|
||||
function registerServer(socket: Socket, message: ControlMessage): ServerPeer {
|
||||
if (message.protocol !== COORDINATOR_PROTOCOL_VERSION) throw new Error("Unsupported coordinator protocol");
|
||||
if (typeof message.serverConnectionId !== "string" || message.serverConnectionId.length === 0) {
|
||||
throw new Error("Coordinator serverConnectionId must be a string");
|
||||
}
|
||||
if (typeof message.endpoint !== "string" || message.endpoint.length === 0) {
|
||||
throw new Error("Coordinator endpoint must be a string");
|
||||
}
|
||||
const { serverConnectionId, endpoint } = message;
|
||||
const server = { serverConnectionId, endpoint, socket };
|
||||
const previous = currentServer;
|
||||
currentServer = server;
|
||||
cancelEmptyShutdown();
|
||||
writeRoutedLine(socket, {
|
||||
type: "server_registered",
|
||||
serverConnectionId,
|
||||
peers: [...peers.keys()],
|
||||
});
|
||||
if (previous && previous !== server) {
|
||||
closePublicConnections();
|
||||
notifyPeers({ type: "server_disconnected", serverConnectionId: previous.serverConnectionId });
|
||||
writeRoutedLine(previous.socket, { type: "server_replaced" });
|
||||
}
|
||||
notifyPeers({ type: "server_connected", serverConnectionId });
|
||||
return server;
|
||||
}
|
||||
|
||||
function registerPeer(socket: Socket, message: ControlMessage): RoutedPeer {
|
||||
if (message.protocol !== COORDINATOR_PROTOCOL_VERSION) throw new Error("Unsupported coordinator protocol");
|
||||
if (typeof message.peerId !== "string" || message.peerId.length === 0) {
|
||||
throw new Error("Coordinator peerId must be a string");
|
||||
}
|
||||
const { peerId } = message;
|
||||
if (peerId === "server" || peers.has(peerId)) throw new Error(`Coordinator peer is already connected: ${peerId}`);
|
||||
const peer = { peerId, socket };
|
||||
peers.set(peerId, peer);
|
||||
cancelEmptyShutdown();
|
||||
writeRoutedLine(socket, {
|
||||
type: "peer_registered",
|
||||
peerId,
|
||||
...(currentServer === undefined ? {} : { serverConnectionId: currentServer.serverConnectionId }),
|
||||
});
|
||||
if (currentServer) {
|
||||
writeRoutedLine(currentServer.socket, { type: "peer_connected", peerId });
|
||||
}
|
||||
return peer;
|
||||
}
|
||||
|
||||
function notifyPeers(message: unknown): void {
|
||||
for (const peer of peers.values()) writeRoutedLine(peer.socket, message);
|
||||
}
|
||||
|
||||
function handleRoutedMessage(from: string, message: ControlMessage): void {
|
||||
if (message.type === "send") {
|
||||
if (typeof message.to !== "string" || message.to.length === 0) {
|
||||
throw new Error("Coordinator message target must be a string");
|
||||
}
|
||||
const { to } = message;
|
||||
const target = to === "server" ? currentServer?.socket : peers.get(to)?.socket;
|
||||
if (target) writeRoutedLine(target, { type: "message", from, payload: message.payload });
|
||||
return;
|
||||
}
|
||||
if (message.type === "broadcast") {
|
||||
if (from !== "server") throw new Error("Only the current server may broadcast");
|
||||
for (const peer of peers.values()) {
|
||||
writeRoutedLine(peer.socket, { type: "message", from, payload: message.payload });
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw new Error(`Unknown coordinator routing message: ${message.type}`);
|
||||
}
|
||||
|
||||
function acceptPublicConnection(client: Socket): void {
|
||||
if (shuttingDown || !currentServer) {
|
||||
client.destroy();
|
||||
return;
|
||||
}
|
||||
cancelEmptyShutdown();
|
||||
const upstream = createConnection(currentServer.endpoint);
|
||||
publicConnections.set(client, upstream);
|
||||
let finalized = false;
|
||||
const finalize = (): void => {
|
||||
if (finalized) return;
|
||||
finalized = true;
|
||||
publicConnections.delete(client);
|
||||
checkEmpty();
|
||||
};
|
||||
upstream.once("connect", () => {
|
||||
client.pipe(upstream);
|
||||
upstream.pipe(client);
|
||||
});
|
||||
upstream.once("error", () => client.destroy());
|
||||
upstream.once("close", () => {
|
||||
client.destroy();
|
||||
finalize();
|
||||
});
|
||||
client.once("error", () => upstream.destroy());
|
||||
client.once("close", () => {
|
||||
upstream.destroy();
|
||||
finalize();
|
||||
});
|
||||
}
|
||||
|
||||
function closePublicConnections(): void {
|
||||
for (const [client, upstream] of publicConnections) {
|
||||
client.destroy();
|
||||
upstream.destroy();
|
||||
}
|
||||
publicConnections.clear();
|
||||
}
|
||||
|
||||
function checkEmpty(): void {
|
||||
if (shuttingDown || currentServer || peers.size > 0 || publicConnections.size > 0 || controlConnections.size > 0) {
|
||||
cancelEmptyShutdown();
|
||||
return;
|
||||
}
|
||||
scheduleEmptyShutdown(EMPTY_SHUTDOWN_GRACE_MS);
|
||||
}
|
||||
|
||||
function scheduleEmptyShutdown(delayMs: number): void {
|
||||
if (emptyTimer || shuttingDown) return;
|
||||
emptyTimer = setTimeout(() => {
|
||||
emptyTimer = undefined;
|
||||
if (!currentServer && peers.size === 0 && publicConnections.size === 0 && controlConnections.size === 0) {
|
||||
void shutdownCoordinator();
|
||||
}
|
||||
}, delayMs);
|
||||
emptyTimer.unref();
|
||||
}
|
||||
|
||||
function cancelEmptyShutdown(): void {
|
||||
if (!emptyTimer) return;
|
||||
clearTimeout(emptyTimer);
|
||||
emptyTimer = undefined;
|
||||
}
|
||||
|
||||
async function shutdownCoordinator(): Promise<void> {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
cancelEmptyShutdown();
|
||||
closePublicConnections();
|
||||
for (const connection of controlConnections) connection.destroy();
|
||||
await Promise.all([closeServer(publicServer), closeServer(controlServer)]);
|
||||
await Promise.all([cleanupSocket(publicPath), cleanupSocket(controlPath)]);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
function writeRoutedLine(socket: Socket, message: unknown): void {
|
||||
if (!socket.destroyed) socket.write(encodeControlLine(message));
|
||||
}
|
||||
|
||||
function attachRoutedLineReader(socket: Socket, onMessage: (message: ControlMessage) => void): void {
|
||||
let buffered = "";
|
||||
socket.setEncoding("utf8");
|
||||
socket.on("data", (chunk: string) => {
|
||||
buffered += chunk;
|
||||
if (Buffer.byteLength(buffered) > MAX_CONTROL_LINE_BYTES) {
|
||||
socket.destroy(new Error("Coordinator message is too large"));
|
||||
return;
|
||||
}
|
||||
while (true) {
|
||||
const newline = buffered.indexOf("\n");
|
||||
if (newline === -1) return;
|
||||
const line = buffered.slice(0, newline);
|
||||
buffered = buffered.slice(newline + 1);
|
||||
try {
|
||||
const message = JSON.parse(line) as ControlMessage | null;
|
||||
if (typeof message?.type !== "string") throw new Error("Coordinator message must have a type");
|
||||
onMessage(message);
|
||||
} catch (error) {
|
||||
socket.destroy(error instanceof Error ? error : new Error(String(error)));
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function listen(server: Server, path: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const onError = (error: Error): void => {
|
||||
server.off("listening", onListening);
|
||||
reject(error);
|
||||
};
|
||||
const onListening = (): void => {
|
||||
server.off("error", onError);
|
||||
resolve();
|
||||
};
|
||||
server.once("error", onError);
|
||||
server.once("listening", onListening);
|
||||
server.listen(path);
|
||||
});
|
||||
}
|
||||
|
||||
function closeServer(server: Server): Promise<void> {
|
||||
if (!server.listening) return Promise.resolve();
|
||||
return new Promise((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
|
||||
async function restrictSocket(path: string): Promise<void> {
|
||||
if (process.platform !== "win32") await chmod(path, 0o600);
|
||||
}
|
||||
|
||||
async function removeStaleSocket(path: string): Promise<void> {
|
||||
try {
|
||||
const stats = await lstat(path);
|
||||
if (!stats.isSocket()) throw new Error(`Coordinator path is not a socket: ${path}`);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "code" in error && error.code === "ENOENT") return;
|
||||
throw error;
|
||||
}
|
||||
const live = await new Promise<boolean>((resolve, reject) => {
|
||||
const socket = createConnection(path);
|
||||
socket.once("connect", () => {
|
||||
socket.destroy();
|
||||
resolve(true);
|
||||
});
|
||||
socket.once("error", (error: NodeJS.ErrnoException) => {
|
||||
socket.destroy();
|
||||
if (error.code === "ECONNREFUSED" || error.code === "ENOENT") resolve(false);
|
||||
else reject(error);
|
||||
});
|
||||
});
|
||||
if (live) throw new Error(`Coordinator socket is already active: ${path}`);
|
||||
await cleanupSocket(path);
|
||||
}
|
||||
|
||||
async function cleanupSocket(path: string): Promise<void> {
|
||||
if (process.platform === "win32") return;
|
||||
await unlink(path).catch((error: unknown) => {
|
||||
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
|
||||
});
|
||||
}
|
||||
|
||||
if (isDirectInternalProcessEntry(import.meta.url)) {
|
||||
const role = consumeInternalProcessRole();
|
||||
if (role !== "coordinator") throw new Error("Coordinator entrypoint requires an internal coordinator invocation");
|
||||
void runCoordinatorProcess(process.argv.slice(2)).catch((error: unknown) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
107
third_party/pi/packages/coding-agent/src/experimental/mini/README.md
vendored
Normal file
107
third_party/pi/packages/coding-agent/src/experimental/mini/README.md
vendored
Normal file
@@ -0,0 +1,107 @@
|
||||
# mini
|
||||
|
||||
A minimal coding agent on the durable `AgentHarness`, split across three processes that talk JSON
|
||||
over a socket and a pipe. It exists to exercise the harness from a real client and to find out what
|
||||
an RPC-shaped presentation actually needs from it.
|
||||
|
||||
```bash
|
||||
node packages/coding-agent/src/experimental/mini/main.ts [--continue]
|
||||
|
||||
# while the harness is changing under us, run from source instead of built dist:
|
||||
./node_modules/.bin/tsx --tsconfig tsconfig.json packages/coding-agent/src/experimental/mini/main.ts
|
||||
```
|
||||
|
||||
`--continue` attaches to the newest session for the current directory. Starting `mini` twice attaches
|
||||
two presentations to the same session; both see the same transcript, live.
|
||||
|
||||
## Topology
|
||||
|
||||
```text
|
||||
tui tui tui presentations: render, no agent state
|
||||
\ | /
|
||||
\ | / unix socket, ~/.pi/agent/experimental/mini.sock
|
||||
\ | /
|
||||
server routes calls, fans out events, spawns workers
|
||||
/ \
|
||||
/ \ child process stdio pipes
|
||||
worker worker one per session: harness, storage, model runtime
|
||||
```
|
||||
|
||||
A presentation holds one connection and reaches every service through it. The server answers
|
||||
`sessions` itself and forwards anything else to the worker that connection is attached to, so the TUI
|
||||
never learns which host answers what. The same rule runs backwards: a worker calling `sessions.list`
|
||||
is answered by the server.
|
||||
|
||||
The first `mini` to start spawns the server detached. The server kills a worker when its last
|
||||
presentation disconnects, and retires itself ten seconds after the last presentation leaves, so the
|
||||
next start always runs current code. If that worker held an open durable operation, its replacement
|
||||
automatically resumes the operation from the last recorded recovery state.
|
||||
|
||||
## Layout
|
||||
|
||||
| path | role |
|
||||
| --- | --- |
|
||||
| `main.ts` | the `mini` command |
|
||||
| `shared/transport.ts` | `Connection`: newline-delimited JSON over any duplex pair |
|
||||
| `shared/rpc.ts` | frames, named services, routing, liveness, cancellation |
|
||||
| `shared/protocol.ts` | service tokens, contracts, wire types |
|
||||
| `server/run.ts` | routes, worker supervision, event fan-out |
|
||||
| `worker/run.ts` | opens the session, builds the harness, provides services |
|
||||
| `worker/lane-service.ts` | `Lane`: watch subscriptions and lane commands |
|
||||
| `worker/models-service.ts` | `Models`: catalog, accounts, interactive login |
|
||||
| `tui/run.ts` | presentation host: ensure server, attach, run the view |
|
||||
| `tui/session.ts` | attach, subscribe, hold the replicated snapshot |
|
||||
| `tui/view.ts` | alt-screen rendering, reusing interactive-mode components |
|
||||
|
||||
## RPC
|
||||
|
||||
Two verbs. **Call** asks a question and gets one answer; **emit** publishes to whoever listens. Both
|
||||
directions on every connection.
|
||||
|
||||
```ts
|
||||
const peer = createPeer(connection, { forward }); // forward handles names this peer lacks
|
||||
peer.provide(Lane, laneService); // register and announce
|
||||
const lane = peer.use(Lane); // Remote<LaneServiceApi>
|
||||
await lane.prompt("hi"); // -> { kind: "call", id, method: "lane.prompt" }
|
||||
peer.on(Lane, (event) => fold(event)); // typed by the token
|
||||
```
|
||||
|
||||
The wire is six frame kinds: `call`, `result`, `error`, `cancel`, `event`, `announce`, `ping`.
|
||||
Replies correlate by `id` and resolve one promise; events carry a service name and no id, so the two
|
||||
never interfere — a five-minute `lane.prompt` sits pending while its stream of events flows past.
|
||||
|
||||
Services are plain objects registered under a token, and a token carries the name plus the call and
|
||||
event types, so `provide` checks the implementation and `use` checks the caller. Names are announced
|
||||
on registration, so routing is a lookup and an unroutable call fails with both inventories named.
|
||||
|
||||
Failure handling: a closed connection rejects every pending call and aborts every in-flight handler;
|
||||
peers ping every five seconds and declare a silent line dead after fifteen, which catches a wedged
|
||||
process that never closes; callers may pass a `signal` (sends `cancel`) or a `timeoutMs`. Timeouts
|
||||
are opt-in per call site, because an agent turn has no bounded duration.
|
||||
|
||||
## State replication
|
||||
|
||||
The worker holds every live object. A presentation holds a `LaneSnapshot` and nothing else.
|
||||
|
||||
Each presentation gets **its own** `lane.watch()` in the worker, in the harness's two phases:
|
||||
`watch()` captures a snapshot while the harness buffers that subscription's events, and `start()`
|
||||
drains them. Because the harness pairs snapshot and stream with no gap and no duplicate, the client
|
||||
needs no buffering or ordering logic of its own — it applies events with the harness's own
|
||||
`reduceLaneSnapshot`. When the fold answers `{ rebase: true }`, which a completed navigation does, the
|
||||
presentation takes a fresh subscription and drops the old one. First attach and rebase are the same
|
||||
function.
|
||||
|
||||
Rendering is a pure function of that snapshot. Transcript entries are keyed by id and appended, so a
|
||||
streamed token costs one markdown rebuild rather than a repaint; the view repaints wholesale only
|
||||
when the entry-id prefix diverges, which is what compaction and navigation do.
|
||||
|
||||
## What it is not
|
||||
|
||||
No slash-command system, extensions, skills, hooks, themes beyond the shared one, session picker, or
|
||||
tree navigation. Interactive login and model selection exist because they proved the awkward
|
||||
directions of the protocol: server-to-client requests, and identity-based configuration.
|
||||
|
||||
Two known shortcuts: the server broadcasts a worker's events to every attached presentation, so with
|
||||
N presentations each event crosses N² times and each client discards the copies that are not its own;
|
||||
and cancelling an interactive login only interrupts at a prompt, since the worker's login has no
|
||||
abort path yet.
|
||||
24
third_party/pi/packages/coding-agent/src/experimental/mini/main.ts
vendored
Normal file
24
third_party/pi/packages/coding-agent/src/experimental/mini/main.ts
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* The `mini` command. Runs the presentation host.
|
||||
*
|
||||
* Run: node packages/coding-agent/src/experimental/mini/main.ts [--continue]
|
||||
*/
|
||||
|
||||
import { runTui, type TuiOptions } from "./tui/run.ts";
|
||||
|
||||
function parseArgs(argv: readonly string[]): TuiOptions {
|
||||
const options: TuiOptions = {};
|
||||
for (const arg of argv) {
|
||||
if (arg === "--continue" || arg === "-c") options.continueSession = true;
|
||||
else throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
runTui(parseArgs(process.argv.slice(2))).then(
|
||||
() => process.exit(0),
|
||||
(error: unknown) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
},
|
||||
);
|
||||
12
third_party/pi/packages/coding-agent/src/experimental/mini/server/entry.ts
vendored
Normal file
12
third_party/pi/packages/coding-agent/src/experimental/mini/server/entry.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
/** Server process entry: `node server/entry.ts <socketPath> <sessionsRoot>`. Spawned detached by the CLI. */
|
||||
|
||||
import { socketTransport } from "../shared/transport.ts";
|
||||
import { runServer } from "./run.ts";
|
||||
|
||||
const [socketPath, sessionsRoot] = process.argv.slice(2);
|
||||
if (!socketPath || !sessionsRoot) throw new Error("Server requires <socketPath> <sessionsRoot>");
|
||||
|
||||
void runServer({ transport: socketTransport(socketPath), sessionsRoot }).catch((error: unknown) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
160
third_party/pi/packages/coding-agent/src/experimental/mini/server/run.ts
vendored
Normal file
160
third_party/pi/packages/coding-agent/src/experimental/mini/server/run.ts
vendored
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Session server: accepts client connections, spawns one worker process per session, routes.
|
||||
*
|
||||
* It provides `Sessions` and holds no agent state. Any other service name is forwarded to the worker
|
||||
* the calling client is attached to, and every worker event is pushed back to that worker's clients.
|
||||
* Workers reach `Sessions` over the same peer, because the routing rule is symmetric.
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { extname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
// Narrow entries: the server routes and lists sessions, it never runs an agent.
|
||||
import { BACKGROUND_CONTEXT } from "@earendil-works/pi-agent-core/harness/context";
|
||||
import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/harness/env/nodejs";
|
||||
import { JsonlSessionRepo } from "@earendil-works/pi-agent-core/harness/session";
|
||||
import { type SessionSummary, Sessions, type SessionsServiceApi, Worker } from "../shared/protocol.ts";
|
||||
import { createPeer, type RpcPeer } from "../shared/rpc.ts";
|
||||
import { childConnection, type Transport } from "../shared/transport.ts";
|
||||
|
||||
const SELF_EXTENSION = extname(fileURLToPath(import.meta.url));
|
||||
const WORKER_ENTRY = fileURLToPath(new URL(`../worker/entry${SELF_EXTENSION}`, import.meta.url));
|
||||
const WORKER_START_TIMEOUT_MS = 30_000;
|
||||
/** Grace period before an idle server retires, so a reconnecting presentation does not race it. */
|
||||
const IDLE_SHUTDOWN_MS = 10_000;
|
||||
|
||||
/** The server's routing entry for one session worker process. The worker knows none of this. */
|
||||
interface Route {
|
||||
sessionId: string;
|
||||
/** Peer to the worker process. */
|
||||
worker: RpcPeer;
|
||||
/** Attached presentations by id, so an addressed event goes to exactly one of them. */
|
||||
subscribers: Map<string, RpcPeer>;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
async function listSessions(sessionsRoot: string): Promise<SessionSummary[]> {
|
||||
const executionEnv = new NodeExecutionEnv({ cwd: process.cwd() });
|
||||
const repo = new JsonlSessionRepo({ fileSystem: executionEnv, sessionsRoot });
|
||||
try {
|
||||
return (await repo.list(undefined, BACKGROUND_CONTEXT)).map((metadata) => ({
|
||||
id: metadata.id,
|
||||
path: metadata.path,
|
||||
cwd: metadata.cwd,
|
||||
createdAt: metadata.createdAt,
|
||||
}));
|
||||
} finally {
|
||||
await repo.close(BACKGROUND_CONTEXT);
|
||||
await executionEnv.cleanup(BACKGROUND_CONTEXT);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runServer(options: { transport: Transport; sessionsRoot: string }): Promise<void> {
|
||||
const routes = new Map<string, Route>();
|
||||
let presentations = 0;
|
||||
let retire = (): void => {};
|
||||
const retired = new Promise<void>((resolve) => {
|
||||
retire = resolve;
|
||||
});
|
||||
let idleTimer: NodeJS.Timeout | undefined;
|
||||
/** Nothing to serve and nothing running: leave, so the next start always gets current code. */
|
||||
const considerRetiring = (): void => {
|
||||
if (idleTimer) clearTimeout(idleTimer);
|
||||
if (presentations > 0 || routes.size > 0) return;
|
||||
idleTimer = setTimeout(() => {
|
||||
if (presentations === 0 && routes.size === 0) retire();
|
||||
}, IDLE_SHUTDOWN_MS);
|
||||
idleTimer.unref();
|
||||
};
|
||||
/** Concurrent attaches to one session must share a worker: two writers would corrupt the session. */
|
||||
const spawning = new Map<string, Promise<Route>>();
|
||||
|
||||
const spawnWorker = async (sessionId: string | undefined, cwd: string): Promise<Route> => {
|
||||
const args = [...process.execArgv, WORKER_ENTRY, options.sessionsRoot, cwd, ...(sessionId ? [sessionId] : [])];
|
||||
const child = spawn(process.execPath, args, { stdio: ["pipe", "pipe", "inherit"] });
|
||||
const connection = childConnection(child);
|
||||
const peer = createPeer(connection);
|
||||
// Workers consume `Sessions` through this same peer.
|
||||
peer.provide(Sessions, { list, attach: attachUnsupported });
|
||||
// A worker that never answers must not wedge the attach that spawned it.
|
||||
const described = await peer.use(Worker, { timeoutMs: WORKER_START_TIMEOUT_MS }).describe();
|
||||
const route: Route = {
|
||||
sessionId: described.sessionId,
|
||||
worker: peer,
|
||||
subscribers: new Map(),
|
||||
stop: () => child.kill(),
|
||||
};
|
||||
// Routing is the server's job. An addressed event reaches one presentation; the rest are shared.
|
||||
peer.onEvent((service, payload, to) => {
|
||||
if (to !== undefined) {
|
||||
route.subscribers.get(to)?.emitRaw(service, payload);
|
||||
return;
|
||||
}
|
||||
for (const subscriber of route.subscribers.values()) subscriber.emitRaw(service, payload);
|
||||
});
|
||||
peer.onClose(() => {
|
||||
routes.delete(route.sessionId);
|
||||
considerRetiring();
|
||||
});
|
||||
routes.set(route.sessionId, route);
|
||||
return route;
|
||||
};
|
||||
|
||||
const ensureRoute = (sessionId: string | null, cwd: string): Promise<Route> => {
|
||||
if (sessionId === null) return spawnWorker(undefined, cwd);
|
||||
const existing = routes.get(sessionId);
|
||||
if (existing) return Promise.resolve(existing);
|
||||
const pending = spawning.get(sessionId);
|
||||
if (pending) return pending;
|
||||
const started = spawnWorker(sessionId, cwd).finally(() => spawning.delete(sessionId));
|
||||
spawning.set(sessionId, started);
|
||||
return started;
|
||||
};
|
||||
|
||||
const list = (): Promise<SessionSummary[]> => listSessions(options.sessionsRoot);
|
||||
const attachUnsupported = async (): Promise<string> => {
|
||||
throw new Error("Only presentations attach to sessions");
|
||||
};
|
||||
|
||||
const listener = await options.transport.listen((connection) => {
|
||||
presentations += 1;
|
||||
let route: Route | undefined;
|
||||
let attachedAs: string | undefined;
|
||||
const sessions: SessionsServiceApi = {
|
||||
list,
|
||||
attach: async (sessionId, cwd, presentationId) => {
|
||||
route?.subscribers.delete(attachedAs ?? "");
|
||||
attachedAs = presentationId;
|
||||
route = await ensureRoute(sessionId, cwd);
|
||||
route.subscribers.set(presentationId, presentation);
|
||||
return route.sessionId;
|
||||
},
|
||||
};
|
||||
const presentation: RpcPeer = createPeer(connection, {
|
||||
forward: (method, args) => {
|
||||
if (!route) throw new Error("Not attached to a session");
|
||||
const service = method.slice(0, method.indexOf("."));
|
||||
if (!route.worker.announced.has(service)) {
|
||||
throw new Error(
|
||||
`No host provides ${service}: server has [${[...presentation.provided]}], worker has [${[...route.worker.announced]}]`,
|
||||
);
|
||||
}
|
||||
return route.worker.call(method, ...args);
|
||||
},
|
||||
});
|
||||
presentation.provide(Sessions, sessions);
|
||||
connection.onClose(() => {
|
||||
presentations -= 1;
|
||||
if (route && attachedAs !== undefined) {
|
||||
route.subscribers.delete(attachedAs);
|
||||
// One worker per session, kept alive only while someone is looking at it.
|
||||
if (route.subscribers.size === 0) route.stop();
|
||||
}
|
||||
considerRetiring();
|
||||
});
|
||||
});
|
||||
|
||||
considerRetiring();
|
||||
await retired;
|
||||
await listener.close();
|
||||
}
|
||||
138
third_party/pi/packages/coding-agent/src/experimental/mini/shared/protocol.ts
vendored
Normal file
138
third_party/pi/packages/coding-agent/src/experimental/mini/shared/protocol.ts
vendored
Normal file
@@ -0,0 +1,138 @@
|
||||
/** Service contracts and everything that crosses the wire. */
|
||||
|
||||
import type { HarnessEvent, LaneSnapshot } from "@earendil-works/pi-agent-core";
|
||||
import type { AuthEvent, AuthPrompt } from "@earendil-works/pi-ai";
|
||||
|
||||
/** Commands answer with data, never exceptions, exactly like a remote call would. */
|
||||
export type CommandResult = { ok: true } | { ok: false; error: string };
|
||||
|
||||
/** Durable model identity. Nothing outside a worker holds a `Model` object. */
|
||||
export interface ModelRef {
|
||||
provider: string;
|
||||
modelId: string;
|
||||
}
|
||||
|
||||
export interface ModelSummary extends ModelRef {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ProviderAccount {
|
||||
id: string;
|
||||
name: string;
|
||||
authType: "oauth" | "api_key";
|
||||
configured: boolean;
|
||||
/** Where the credential came from, for display: "stored", "environment", an env var name. */
|
||||
source?: string;
|
||||
/** False for ambient credentials pi cannot collect itself, such as AWS profiles or env vars. */
|
||||
interactive: boolean;
|
||||
methodName?: string;
|
||||
}
|
||||
|
||||
export interface ModelsState {
|
||||
readonly models: readonly ModelSummary[];
|
||||
readonly accounts: readonly ProviderAccount[];
|
||||
readonly refreshing: boolean;
|
||||
}
|
||||
|
||||
/** An auth prompt without its `AbortSignal`: the part that can cross a transport. */
|
||||
export type AuthPromptRequest = AuthPrompt extends infer Prompt
|
||||
? Prompt extends unknown
|
||||
? Omit<Prompt, "signal">
|
||||
: never
|
||||
: never;
|
||||
|
||||
/** A service seen from the other side of a connection: every method returns a promise. */
|
||||
export type Remote<T> = {
|
||||
[K in keyof T]: T[K] extends (...args: infer A) => infer R ? (...args: A) => Promise<Awaited<R>> : never;
|
||||
};
|
||||
|
||||
/** Names one service and carries its call and event types. Names are globally unique. */
|
||||
export interface ServiceToken<TApi extends object, TEvent = never> {
|
||||
readonly name: string;
|
||||
/** Phantom, never read: keeps the types attached to the token. */
|
||||
readonly types?: (api: TApi, event: TEvent) => void;
|
||||
}
|
||||
|
||||
export function defineService<TApi extends object, TEvent = never>(name: string): ServiceToken<TApi, TEvent> {
|
||||
return { name };
|
||||
}
|
||||
|
||||
export interface SessionSummary {
|
||||
id: string;
|
||||
path: string;
|
||||
cwd: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface SessionSnapshot {
|
||||
sessionId: string;
|
||||
cwd: string;
|
||||
sessionPath: string;
|
||||
/** Carries the lane configuration, queues, and stats: no side-channel replication. */
|
||||
lane: LaneSnapshot;
|
||||
models: ModelsState;
|
||||
}
|
||||
|
||||
/** Everything the `Models` service publishes. */
|
||||
export type ModelsEvent =
|
||||
| { type: "state"; state: ModelsState }
|
||||
// Login runs the wrong way round: the request is an event, the answer is an ordinary call.
|
||||
| { type: "prompt"; requestId: string; request: AuthPromptRequest }
|
||||
| { type: "notice"; notice: AuthEvent };
|
||||
|
||||
/** The login half, for whatever drives the dialog. */
|
||||
export type AuthEventPayload = Exclude<ModelsEvent, { type: "state" }>;
|
||||
|
||||
/** One presentation's subscription: a `lane.watch()` in the worker, named so its events can be filtered. */
|
||||
export interface LaneSubscription {
|
||||
subscriptionId: string;
|
||||
snapshot: SessionSnapshot;
|
||||
}
|
||||
|
||||
/** Lane events are addressed to the subscription whose watch produced them. */
|
||||
export interface LaneEvent {
|
||||
subscriptionId: string;
|
||||
event: HarnessEvent;
|
||||
}
|
||||
|
||||
export interface LaneServiceApi {
|
||||
/**
|
||||
* Capture a snapshot and open a subscription for one presentation. Its events are addressed to
|
||||
* `presentationId`, so the server routes them instead of broadcasting. Buffered until `start`.
|
||||
*/
|
||||
watch(presentationId: string): Promise<LaneSubscription>;
|
||||
/** Begin delivery, draining everything buffered since the snapshot. */
|
||||
start(subscriptionId: string): Promise<void>;
|
||||
unwatch(subscriptionId: string): Promise<void>;
|
||||
prompt(text: string): Promise<CommandResult>;
|
||||
steer(text: string): Promise<CommandResult>;
|
||||
followUp(text: string): Promise<CommandResult>;
|
||||
compact(): Promise<CommandResult>;
|
||||
abort(): Promise<CommandResult>;
|
||||
setModel(ref: ModelRef): Promise<CommandResult>;
|
||||
}
|
||||
|
||||
export interface ModelsServiceApi {
|
||||
refresh(): Promise<CommandResult>;
|
||||
login(providerId: string, authType: "oauth" | "api_key"): Promise<CommandResult>;
|
||||
authReply(requestId: string, answer: string | null): Promise<void>;
|
||||
}
|
||||
|
||||
/** Provided by a worker so the server can identify the session it opened, without naming lane methods. */
|
||||
export interface WorkerServiceApi {
|
||||
describe(): Promise<{ sessionId: string }>;
|
||||
}
|
||||
|
||||
export interface SessionsServiceApi {
|
||||
list(): Promise<SessionSummary[]>;
|
||||
attach(sessionId: string | null, cwd: string, presentationId: string): Promise<string>;
|
||||
}
|
||||
|
||||
/** Provided by the worker. One subscription per presentation; `watch` again to rebase. */
|
||||
export const Lane = defineService<LaneServiceApi, LaneEvent>("lane");
|
||||
/** Provided by the worker. Small enough to publish whole. */
|
||||
export const Models = defineService<ModelsServiceApi, ModelsEvent>("models");
|
||||
/** Provided by the worker, consumed only by the server. */
|
||||
export const Worker = defineService<WorkerServiceApi>("worker");
|
||||
/** Provided by the server. */
|
||||
export const Sessions = defineService<SessionsServiceApi>("sessions");
|
||||
233
third_party/pi/packages/coding-agent/src/experimental/mini/shared/rpc.ts
vendored
Normal file
233
third_party/pi/packages/coding-agent/src/experimental/mini/shared/rpc.ts
vendored
Normal file
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* The whole protocol: call, result, error, cancel, event, ping. Plus named services and one routing
|
||||
* rule.
|
||||
*
|
||||
* A peer provides any number of services and uses the other side's. A call for a service this peer
|
||||
* does not provide goes to `forward`, which is what makes the server transparent: a TUI uses
|
||||
* `lane.prompt`, the server does not provide `lane`, so it hands the call to the attached worker.
|
||||
* The same rule lets a worker use `sessions.list` back through the server.
|
||||
*/
|
||||
|
||||
import type { Remote, ServiceToken } from "./protocol.ts";
|
||||
import type { Connection } from "./transport.ts";
|
||||
|
||||
type Frame =
|
||||
| { kind: "call"; id: number; method: string; args: unknown[] }
|
||||
| { kind: "result"; id: number; result: unknown }
|
||||
| { kind: "error"; id: number; error: string }
|
||||
| { kind: "cancel"; id: number }
|
||||
| { kind: "event"; service: string; payload: unknown; to?: string }
|
||||
| { kind: "announce"; services: string[] }
|
||||
| { kind: "ping" };
|
||||
|
||||
export type Forward = (method: string, args: unknown[]) => Promise<unknown>;
|
||||
|
||||
export interface CallOptions {
|
||||
/** Abandon the call and tell the peer to stop. */
|
||||
signal?: AbortSignal;
|
||||
/** Reject if the peer has not answered in time. Omit for calls with no bounded duration. */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface PeerOptions {
|
||||
/** Handles calls for services this peer does not provide. */
|
||||
forward?: Forward;
|
||||
/** Silence tolerated before the peer is declared gone. Default 15s; 0 disables liveness. */
|
||||
deadMs?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_DEAD_MS = 15_000;
|
||||
|
||||
export interface RpcPeer {
|
||||
/** Register an implementation and announce the name to the other side. */
|
||||
provide<TApi extends object, TEvent>(token: ServiceToken<TApi, TEvent>, implementation: TApi): void;
|
||||
/** Services this peer provides. */
|
||||
readonly provided: ReadonlySet<string>;
|
||||
/** Services the other side announced. */
|
||||
readonly announced: ReadonlySet<string>;
|
||||
/** Use a service, wherever it is provided: this peer's other side, or its next hop. */
|
||||
use<TApi extends object, TEvent>(token: ServiceToken<TApi, TEvent>, options?: CallOptions): Remote<TApi>;
|
||||
/** Publish to everyone listening on the other side. */
|
||||
emit<TApi extends object, TEvent>(token: ServiceToken<TApi, TEvent>, event: TEvent): void;
|
||||
/** Publish for one destination. A router delivers it there instead of broadcasting. */
|
||||
emitTo<TApi extends object, TEvent>(token: ServiceToken<TApi, TEvent>, event: TEvent, to: string): void;
|
||||
on<TApi extends object, TEvent>(token: ServiceToken<TApi, TEvent>, handler: (event: TEvent) => void): void;
|
||||
/** Router half of the event channel: observe and republish without knowing the service. */
|
||||
onEvent(handler: (service: string, payload: unknown, to: string | undefined) => void): void;
|
||||
emitRaw(service: string, payload: unknown, to?: string): void;
|
||||
call(method: string, ...args: unknown[]): Promise<unknown>;
|
||||
callWith(options: CallOptions, method: string, ...args: unknown[]): Promise<unknown>;
|
||||
onClose(handler: () => void): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
/** A bidirectional peer on one connection. */
|
||||
export function createPeer(connection: Connection, options: PeerOptions = {}): RpcPeer {
|
||||
const services = new Map<string, object>();
|
||||
const provided = new Set<string>();
|
||||
/** What the other side told us it provides, so routing is a lookup rather than a guess. */
|
||||
const announced = new Set<string>();
|
||||
const pending = new Map<number, { resolve: (value: unknown) => void; reject: (error: Error) => void }>();
|
||||
/** Controllers for calls this peer is currently answering, so a `cancel` frame can stop them. */
|
||||
const inflight = new Map<number, AbortController>();
|
||||
const eventHandlers: ((service: string, payload: unknown, to: string | undefined) => void)[] = [];
|
||||
let nextId = 1;
|
||||
let lastFrameAt = Date.now();
|
||||
|
||||
// The signal is appended to every handler call: services that care declare a trailing
|
||||
// `AbortSignal` parameter, the rest ignore an extra argument.
|
||||
const dispatch = async (method: string, args: unknown[], signal: AbortSignal): Promise<unknown> => {
|
||||
const dot = method.indexOf(".");
|
||||
const local = dot === -1 ? undefined : services.get(method.slice(0, dot));
|
||||
if (!local) {
|
||||
if (!options.forward) throw new Error(`No service provides ${method}`);
|
||||
return options.forward(method, args);
|
||||
}
|
||||
const handler = (local as Record<string, unknown>)[method.slice(dot + 1)];
|
||||
if (typeof handler !== "function") throw new Error(`Unknown method: ${method}`);
|
||||
return (handler as (...args: unknown[]) => unknown).apply(local, [...args, signal]);
|
||||
};
|
||||
|
||||
connection.onMessage((frameValue) => {
|
||||
const frame = frameValue as Frame;
|
||||
lastFrameAt = Date.now();
|
||||
switch (frame.kind) {
|
||||
case "event": {
|
||||
for (const handler of eventHandlers) handler(frame.service, frame.payload, frame.to);
|
||||
return;
|
||||
}
|
||||
case "call": {
|
||||
const controller = new AbortController();
|
||||
inflight.set(frame.id, controller);
|
||||
void dispatch(frame.method, frame.args, controller.signal)
|
||||
.then(
|
||||
// `undefined` vanishes through JSON, so an absent result is sent as null.
|
||||
(result) => connection.send({ kind: "result", id: frame.id, result: result ?? null }),
|
||||
(error: unknown) => connection.send({ kind: "error", id: frame.id, error: message(error) }),
|
||||
)
|
||||
.finally(() => inflight.delete(frame.id));
|
||||
return;
|
||||
}
|
||||
case "cancel": {
|
||||
inflight.get(frame.id)?.abort(new Error("Cancelled by caller"));
|
||||
inflight.delete(frame.id);
|
||||
return;
|
||||
}
|
||||
case "result":
|
||||
case "error": {
|
||||
const waiter = pending.get(frame.id);
|
||||
pending.delete(frame.id);
|
||||
if (frame.kind === "error") waiter?.reject(new Error(frame.error));
|
||||
else waiter?.resolve(frame.result);
|
||||
return;
|
||||
}
|
||||
case "announce": {
|
||||
announced.clear();
|
||||
for (const service of frame.services) announced.add(service);
|
||||
return;
|
||||
}
|
||||
case "ping":
|
||||
return;
|
||||
default: {
|
||||
const unknownFrame: never = frame;
|
||||
throw new Error(`Unknown frame: ${JSON.stringify(unknownFrame)}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
connection.onClose(() => {
|
||||
if (liveness) clearInterval(liveness);
|
||||
for (const waiter of pending.values()) waiter.reject(new Error("Connection closed"));
|
||||
pending.clear();
|
||||
for (const controller of inflight.values()) controller.abort(new Error("Connection closed"));
|
||||
inflight.clear();
|
||||
});
|
||||
|
||||
/**
|
||||
* A peer can vanish without closing: a killed machine, a wedged event loop. Any frame counts as
|
||||
* proof of life, and pings keep an idle connection proving it.
|
||||
*/
|
||||
const deadMs = options.deadMs ?? DEFAULT_DEAD_MS;
|
||||
const liveness =
|
||||
deadMs > 0
|
||||
? setInterval(
|
||||
() => {
|
||||
if (Date.now() - lastFrameAt > deadMs) connection.close();
|
||||
else connection.send({ kind: "ping" });
|
||||
},
|
||||
Math.floor(deadMs / 3),
|
||||
)
|
||||
: undefined;
|
||||
liveness?.unref();
|
||||
|
||||
const callWith = (callOptions: CallOptions, method: string, ...args: unknown[]): Promise<unknown> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const id = nextId++;
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const abandon = (error: Error): void => {
|
||||
if (!pending.delete(id)) return;
|
||||
if (timer) clearTimeout(timer);
|
||||
callOptions.signal?.removeEventListener("abort", onAbort);
|
||||
// Tell the peer to stop; it may already be gone, in which case this is a no-op.
|
||||
connection.send({ kind: "cancel", id });
|
||||
reject(error);
|
||||
};
|
||||
const onAbort = (): void => abandon(new Error("Call cancelled"));
|
||||
const settle =
|
||||
<T>(handler: (value: T) => void) =>
|
||||
(value: T) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
callOptions.signal?.removeEventListener("abort", onAbort);
|
||||
handler(value);
|
||||
};
|
||||
if (callOptions.signal?.aborted) {
|
||||
reject(new Error("Call cancelled"));
|
||||
return;
|
||||
}
|
||||
pending.set(id, { resolve: settle(resolve), reject: settle(reject) });
|
||||
callOptions.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
if (callOptions.timeoutMs !== undefined) {
|
||||
timer = setTimeout(
|
||||
() => abandon(new Error(`${method} timed out after ${callOptions.timeoutMs}ms`)),
|
||||
callOptions.timeoutMs,
|
||||
);
|
||||
timer.unref();
|
||||
}
|
||||
connection.send({ kind: "call", id, method, args });
|
||||
});
|
||||
|
||||
const peer: RpcPeer = {
|
||||
provide: (token, implementation) => {
|
||||
services.set(token.name, implementation);
|
||||
provided.add(token.name);
|
||||
connection.send({ kind: "announce", services: [...provided] });
|
||||
},
|
||||
provided,
|
||||
announced,
|
||||
use: (token, callOptions = {}) =>
|
||||
new Proxy({} as Remote<typeof token extends ServiceToken<infer TApi, never> ? TApi : never>, {
|
||||
get:
|
||||
(_target, method) =>
|
||||
(...args: unknown[]) =>
|
||||
callWith(callOptions, `${token.name}.${String(method)}`, ...args),
|
||||
}) as never,
|
||||
emit: (token, event) => connection.send({ kind: "event", service: token.name, payload: event }),
|
||||
emitTo: (token, event, to) => connection.send({ kind: "event", service: token.name, payload: event, to }),
|
||||
emitRaw: (service, payload, to) =>
|
||||
connection.send({ kind: "event", service, payload, ...(to === undefined ? {} : { to }) }),
|
||||
on: (token, handler) =>
|
||||
eventHandlers.push((name, payload) => {
|
||||
if (name === token.name) handler(payload as never);
|
||||
}),
|
||||
onEvent: (handler) => eventHandlers.push(handler),
|
||||
call: (method, ...args) => callWith({}, method, ...args),
|
||||
callWith,
|
||||
onClose: (handler) => connection.onClose(handler),
|
||||
close: () => connection.close(),
|
||||
};
|
||||
return peer;
|
||||
}
|
||||
|
||||
function message(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
119
third_party/pi/packages/coding-agent/src/experimental/mini/shared/transport.ts
vendored
Normal file
119
third_party/pi/packages/coding-agent/src/experimental/mini/shared/transport.ts
vendored
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Pluggable transport: newline-delimited JSON over any duplex pair.
|
||||
*
|
||||
* `Connection` is the shared abstraction and every host uses it. `Transport` is only for hops that
|
||||
* have an address to negotiate: the unix socket between presentations and the server. A spawned
|
||||
* worker needs none, because the pipes exist before it does.
|
||||
*/
|
||||
|
||||
import { rm } from "node:fs/promises";
|
||||
import { createConnection as connectSocket, createServer, type Socket } from "node:net";
|
||||
|
||||
export interface Connection {
|
||||
send(message: unknown): void;
|
||||
onMessage(handler: (message: unknown) => void): void;
|
||||
onClose(handler: () => void): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export interface Listener {
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface Transport {
|
||||
listen(onConnection: (connection: Connection) => void): Promise<Listener>;
|
||||
connect(): Promise<Connection>;
|
||||
}
|
||||
|
||||
/** Frame JSON messages as one line each over a readable/writable pair. */
|
||||
export function jsonConnection(
|
||||
input: NodeJS.ReadableStream,
|
||||
output: NodeJS.WritableStream,
|
||||
close: () => void,
|
||||
): Connection {
|
||||
const messageHandlers: ((message: unknown) => void)[] = [];
|
||||
const closeHandlers: (() => void)[] = [];
|
||||
let buffered = "";
|
||||
let closed = false;
|
||||
const notifyClosed = (): void => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
for (const handler of closeHandlers) handler();
|
||||
};
|
||||
input.setEncoding("utf8");
|
||||
input.on("data", (chunk: string) => {
|
||||
buffered += chunk;
|
||||
let newline = buffered.indexOf("\n");
|
||||
while (newline !== -1) {
|
||||
const line = buffered.slice(0, newline);
|
||||
buffered = buffered.slice(newline + 1);
|
||||
if (line.length > 0) {
|
||||
const message: unknown = JSON.parse(line);
|
||||
for (const handler of messageHandlers) handler(message);
|
||||
}
|
||||
newline = buffered.indexOf("\n");
|
||||
}
|
||||
});
|
||||
input.on("end", notifyClosed);
|
||||
input.on("error", notifyClosed);
|
||||
output.on("error", notifyClosed);
|
||||
return {
|
||||
send: (message) => {
|
||||
if (!closed) output.write(`${JSON.stringify(message)}\n`);
|
||||
},
|
||||
onMessage: (handler) => messageHandlers.push(handler),
|
||||
onClose: (handler) => closeHandlers.push(handler),
|
||||
close: () => {
|
||||
notifyClosed();
|
||||
close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function socketConnection(socket: Socket): Connection {
|
||||
return jsonConnection(socket, socket, () => socket.destroy());
|
||||
}
|
||||
|
||||
/**
|
||||
* A spawned child is connected at birth, so there is no address to dial and no `Transport`: the
|
||||
* parent reads the child's stdout and writes its stdin, and the child sees the same pipes reversed.
|
||||
*/
|
||||
export function childConnection(child: {
|
||||
stdin: NodeJS.WritableStream | null;
|
||||
stdout: NodeJS.ReadableStream | null;
|
||||
kill(): unknown;
|
||||
}): Connection {
|
||||
if (!child.stdin || !child.stdout) throw new Error("Child process was spawned without pipes");
|
||||
return jsonConnection(child.stdout, child.stdin, () => child.kill());
|
||||
}
|
||||
|
||||
/** The child's own view of the pipes its parent created. */
|
||||
export function parentConnection(): Connection {
|
||||
return jsonConnection(process.stdin, process.stdout, () => process.stdin.pause());
|
||||
}
|
||||
|
||||
export function socketTransport(path: string): Transport {
|
||||
return {
|
||||
async listen(onConnection) {
|
||||
await rm(path, { force: true });
|
||||
const server = createServer((socket) => onConnection(socketConnection(socket)));
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(path, resolve);
|
||||
});
|
||||
return {
|
||||
close: () =>
|
||||
new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
}),
|
||||
};
|
||||
},
|
||||
connect() {
|
||||
return new Promise<Connection>((resolve, reject) => {
|
||||
const socket = connectSocket(path);
|
||||
socket.once("connect", () => resolve(socketConnection(socket)));
|
||||
socket.once("error", reject);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
68
third_party/pi/packages/coding-agent/src/experimental/mini/tui/run.ts
vendored
Normal file
68
third_party/pi/packages/coding-agent/src/experimental/mini/tui/run.ts
vendored
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* The presentation host: find or start the session server, attach to a session, run the view.
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { extname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { getAgentDir } from "../../../config.ts";
|
||||
import { socketTransport, type Transport } from "../shared/transport.ts";
|
||||
import { connect, listSessions } from "./session.ts";
|
||||
import { runView } from "./view.ts";
|
||||
|
||||
const SELF_EXTENSION = extname(fileURLToPath(import.meta.url));
|
||||
const SERVER_ENTRY = fileURLToPath(new URL(`../server/entry${SELF_EXTENSION}`, import.meta.url));
|
||||
const SERVER_START_TIMEOUT_MS = 10_000;
|
||||
|
||||
export interface TuiOptions {
|
||||
cwd?: string;
|
||||
continueSession?: boolean;
|
||||
}
|
||||
|
||||
async function ensureServer(transport: Transport, socketPath: string, sessionsRoot: string): Promise<void> {
|
||||
try {
|
||||
(await transport.connect()).close();
|
||||
return;
|
||||
} catch {
|
||||
// No server yet; start one.
|
||||
}
|
||||
// execArgv is forwarded so a parent running under a TypeScript loader produces children that do too.
|
||||
const child = spawn(process.execPath, [...process.execArgv, SERVER_ENTRY, socketPath, sessionsRoot], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
});
|
||||
child.unref();
|
||||
const deadline = Date.now() + SERVER_START_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
(await transport.connect()).close();
|
||||
return;
|
||||
} catch {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
}
|
||||
throw new Error("Timed out waiting for the mini session server");
|
||||
}
|
||||
|
||||
export async function runTui(options: TuiOptions = {}): Promise<void> {
|
||||
const cwd = options.cwd ?? process.cwd();
|
||||
const root = join(getAgentDir(), "experimental");
|
||||
await mkdir(root, { recursive: true });
|
||||
const socketPath = join(root, "mini.sock");
|
||||
const sessionsRoot = join(root, "mini-sessions");
|
||||
const transport = socketTransport(socketPath);
|
||||
await ensureServer(transport, socketPath, sessionsRoot);
|
||||
|
||||
let sessionId: string | null = null;
|
||||
if (options.continueSession) {
|
||||
const sessions = (await listSessions(transport)).filter((session) => session.cwd === cwd);
|
||||
sessionId = sessions.sort((left, right) => left.createdAt - right.createdAt).at(-1)?.id ?? null;
|
||||
}
|
||||
const client = await connect(transport, sessionId, cwd);
|
||||
try {
|
||||
await runView(client);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
117
third_party/pi/packages/coding-agent/src/experimental/mini/tui/session.ts
vendored
Normal file
117
third_party/pi/packages/coding-agent/src/experimental/mini/tui/session.ts
vendored
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* The presentation's half of a session: one connection, services by token, one replicated snapshot.
|
||||
*
|
||||
* `Sessions` is answered by the server and `Lane`/`Models` by the worker, but both are reached
|
||||
* through this one peer, so the view never learns which host provides what.
|
||||
*
|
||||
* Alignment is not this file's problem. `lane.watch()` captures a snapshot in the worker and buffers
|
||||
* that subscription's events there; `lane.start()` drains them. Nothing can arrive before this
|
||||
* presentation holds the snapshot and knows its subscription id, so there is nothing to buffer here.
|
||||
*
|
||||
* The fold is the harness's `reduceLaneSnapshot`: a replica must not have a second opinion.
|
||||
*/
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { HarnessEvent } from "@earendil-works/pi-agent-core";
|
||||
// Narrow entry: the fold is one file, so a presentation never evaluates the harness barrel.
|
||||
import { reduceLaneSnapshot } from "@earendil-works/pi-agent-core/harness/runtime/reducer";
|
||||
import {
|
||||
type AuthEventPayload,
|
||||
Lane,
|
||||
type LaneServiceApi,
|
||||
Models,
|
||||
type ModelsServiceApi,
|
||||
type Remote,
|
||||
type SessionSnapshot,
|
||||
type SessionSummary,
|
||||
Sessions,
|
||||
} from "../shared/protocol.ts";
|
||||
import { createPeer } from "../shared/rpc.ts";
|
||||
import type { Transport } from "../shared/transport.ts";
|
||||
|
||||
export interface AttachedSession {
|
||||
state(): SessionSnapshot;
|
||||
subscribe(listener: () => void): () => void;
|
||||
onAuth(handler: (event: AuthEventPayload) => void): void;
|
||||
readonly lane: Remote<LaneServiceApi>;
|
||||
readonly models: Remote<ModelsServiceApi>;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
/** Attach spawns a worker when none is running; runs and other lane calls stay unbounded. */
|
||||
const ATTACH_TIMEOUT_MS = 60_000;
|
||||
|
||||
export async function listSessions(transport: Transport): Promise<SessionSummary[]> {
|
||||
const peer = createPeer(await transport.connect());
|
||||
try {
|
||||
return await peer.use(Sessions).list();
|
||||
} finally {
|
||||
peer.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** Attach to `sessionId`, or to a new session when it is null. */
|
||||
export async function connect(transport: Transport, sessionId: string | null, cwd: string): Promise<AttachedSession> {
|
||||
const peer = createPeer(await transport.connect());
|
||||
const lane = peer.use(Lane);
|
||||
/** This presentation's identity: the server routes our lane events by it. */
|
||||
const presentationId = randomUUID();
|
||||
const listeners = new Set<() => void>();
|
||||
const authHandlers = new Set<(event: AuthEventPayload) => void>();
|
||||
const publish = (): void => {
|
||||
for (const listener of listeners) listener();
|
||||
};
|
||||
|
||||
let snapshot: SessionSnapshot | undefined;
|
||||
let subscriptionId: string | undefined;
|
||||
|
||||
const fold = (event: HarnessEvent): void => {
|
||||
if (!snapshot) return;
|
||||
if (reduceLaneSnapshot(snapshot.lane, event) === "rebase") {
|
||||
void resubscribe();
|
||||
return;
|
||||
}
|
||||
publish();
|
||||
};
|
||||
|
||||
/** First attach and rebase are the same operation: take a new subscription, drop the old one. */
|
||||
const resubscribe = async (): Promise<void> => {
|
||||
const previous = subscriptionId;
|
||||
const opened = await lane.watch(presentationId);
|
||||
snapshot = opened.snapshot;
|
||||
subscriptionId = opened.subscriptionId;
|
||||
publish();
|
||||
await lane.start(opened.subscriptionId);
|
||||
if (previous) void lane.unwatch(previous);
|
||||
};
|
||||
|
||||
peer.on(Lane, (event) => {
|
||||
// Addressed to us by the server; the id check discards a superseded subscription after a rebase.
|
||||
if (event.subscriptionId === subscriptionId) fold(event.event);
|
||||
});
|
||||
peer.on(Models, (event) => {
|
||||
if (event.type !== "state") {
|
||||
// Login prompts and notices are not state; they drive the dialog.
|
||||
for (const handler of authHandlers) handler(event);
|
||||
return;
|
||||
}
|
||||
if (!snapshot) return;
|
||||
snapshot = { ...snapshot, models: event.state };
|
||||
publish();
|
||||
});
|
||||
|
||||
await peer.use(Sessions, { timeoutMs: ATTACH_TIMEOUT_MS }).attach(sessionId, cwd, presentationId);
|
||||
await resubscribe();
|
||||
|
||||
return {
|
||||
state: () => snapshot as SessionSnapshot,
|
||||
subscribe: (listener) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
onAuth: (handler) => authHandlers.add(handler),
|
||||
lane,
|
||||
models: peer.use(Models),
|
||||
close: () => peer.close(),
|
||||
};
|
||||
}
|
||||
555
third_party/pi/packages/coding-agent/src/experimental/mini/tui/view.ts
vendored
Normal file
555
third_party/pi/packages/coding-agent/src/experimental/mini/tui/view.ts
vendored
Normal file
@@ -0,0 +1,555 @@
|
||||
/**
|
||||
* The view. It holds no live objects: no harness, lane, session, or model runtime.
|
||||
*
|
||||
* Everything it renders comes from a replicated `SessionView` snapshot, and everything it does is a
|
||||
* command that answers with data. Whether that view is in-process or a socket away is invisible here.
|
||||
*/
|
||||
|
||||
import type { AgentMessage, Entry, LaneSnapshot } from "@earendil-works/pi-agent-core";
|
||||
import type { AssistantMessage, AuthEvent } from "@earendil-works/pi-ai";
|
||||
import {
|
||||
type Component,
|
||||
Container,
|
||||
type Focusable,
|
||||
fuzzyFilter,
|
||||
getKeybindings,
|
||||
Input,
|
||||
ProcessTerminal,
|
||||
ScrollView,
|
||||
type SelectItem,
|
||||
SelectList,
|
||||
type SelectListTheme,
|
||||
Spacer,
|
||||
setKeybindings,
|
||||
Text,
|
||||
TruncatedText,
|
||||
TuiAltScreen,
|
||||
VStack,
|
||||
} from "@earendil-works/pi-tui";
|
||||
import { getAgentDir } from "../../../config.ts";
|
||||
import { KeybindingsManager } from "../../../core/keybindings.ts";
|
||||
import { createAllToolRenderers } from "../../../core/tools/renderers/index.ts";
|
||||
import { AssistantMessageComponent } from "../../../modes/interactive/components/assistant-message.ts";
|
||||
import { CustomEditor } from "../../../modes/interactive/components/custom-editor.ts";
|
||||
import { DynamicBorder } from "../../../modes/interactive/components/dynamic-border.ts";
|
||||
import { ExtensionSelectorComponent } from "../../../modes/interactive/components/extension-selector.ts";
|
||||
import { keyText } from "../../../modes/interactive/components/keybinding-hints.ts";
|
||||
import { LoginDialogComponent } from "../../../modes/interactive/components/login-dialog.ts";
|
||||
import {
|
||||
type AuthSelectorProvider,
|
||||
OAuthSelectorComponent,
|
||||
} from "../../../modes/interactive/components/oauth-selector.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 { getEditorTheme, initTheme, theme } from "../../../modes/interactive/theme/theme.ts";
|
||||
import type { AuthPromptRequest, CommandResult, ProviderAccount } from "../shared/protocol.ts";
|
||||
import type { AttachedSession } from "./session.ts";
|
||||
|
||||
const SUBSCRIPTION_LOGIN_LABEL = "Sign in with an account";
|
||||
const API_KEY_LOGIN_LABEL = "Sign in with an API key";
|
||||
|
||||
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("");
|
||||
}
|
||||
|
||||
const SELECT_THEME: SelectListTheme = {
|
||||
selectedPrefix: (text) => theme.fg("accent", text),
|
||||
selectedText: (text) => theme.fg("accent", text),
|
||||
description: (text) => theme.fg("muted", text),
|
||||
scrollInfo: (text) => theme.fg("dim", text),
|
||||
noMatch: (text) => theme.fg("warning", text),
|
||||
};
|
||||
|
||||
/** Fuzzy-searchable list over plain data, used where interactive mode needs a live ModelRuntime. */
|
||||
class ListSelector extends Container implements Focusable {
|
||||
readonly #input = new Input();
|
||||
readonly #listContainer = new Container();
|
||||
readonly #items: SelectItem[];
|
||||
readonly #onSelect: (value: string) => void;
|
||||
readonly #onCancel: () => void;
|
||||
#list: SelectList;
|
||||
#isFocused = false;
|
||||
|
||||
constructor(title: string, items: SelectItem[], onSelect: (value: string) => void, onCancel: () => void) {
|
||||
super();
|
||||
this.#items = items;
|
||||
this.#onSelect = onSelect;
|
||||
this.#onCancel = onCancel;
|
||||
this.#list = this.#build(items);
|
||||
this.addChild(new DynamicBorder());
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
|
||||
this.addChild(this.#input);
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(this.#listContainer);
|
||||
this.addChild(new DynamicBorder());
|
||||
}
|
||||
|
||||
get focused(): boolean {
|
||||
return this.#isFocused;
|
||||
}
|
||||
|
||||
set focused(value: boolean) {
|
||||
this.#isFocused = value;
|
||||
this.#input.focused = value;
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
const keybindings = getKeybindings();
|
||||
const forwarded = ["tui.select.up", "tui.select.down", "tui.select.confirm", "tui.select.cancel"] as const;
|
||||
if (forwarded.some((action) => keybindings.matches(data, action))) {
|
||||
this.#list.handleInput(data);
|
||||
return;
|
||||
}
|
||||
this.#input.handleInput(data);
|
||||
const query = this.#input.getValue();
|
||||
const filtered =
|
||||
query.length === 0 ? this.#items : fuzzyFilter(this.#items, query, (item) => `${item.label} ${item.value}`);
|
||||
this.#list = this.#build(filtered);
|
||||
}
|
||||
|
||||
#build(items: SelectItem[]): SelectList {
|
||||
const list = new SelectList(items, 10, SELECT_THEME);
|
||||
list.onSelect = (item) => this.#onSelect(item.value);
|
||||
list.onCancel = () => this.#onCancel();
|
||||
this.#listContainer.clear();
|
||||
this.#listContainer.addChild(list);
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
interface MiniTuiHandlers {
|
||||
submit(text: string): void;
|
||||
/** Queue the current editor text as a follow-up instead of steering the active run. */
|
||||
queueFollowUp(text: string): void;
|
||||
interrupt(): void;
|
||||
exit(): void;
|
||||
selectModel(): void;
|
||||
}
|
||||
|
||||
/** Alt-screen chat surface. Rendering is a function of the replicated snapshot. */
|
||||
class MiniTui {
|
||||
readonly #ui: TuiAltScreen;
|
||||
readonly #chat = new Container();
|
||||
readonly #queue = new Container();
|
||||
readonly #status = new Container();
|
||||
readonly #footer = new Text("", 1, 0);
|
||||
readonly #editorContainer = new Container();
|
||||
readonly #editor: CustomEditor;
|
||||
readonly #cwd: string;
|
||||
readonly #tools = new Map<string, ToolExecutionComponent>();
|
||||
/** Entry ids already painted, in transcript order, so re-renders only append. */
|
||||
#renderedEntryIds: string[] = [];
|
||||
#streaming: AssistantMessageComponent | undefined;
|
||||
#indicator: StatusIndicator | undefined;
|
||||
#working = false;
|
||||
#mountedDispose: (() => void) | undefined;
|
||||
|
||||
constructor(cwd: string, handlers: MiniTuiHandlers) {
|
||||
this.#cwd = cwd;
|
||||
this.#ui = new TuiAltScreen(new ProcessTerminal(), false, getAgentDir());
|
||||
const keybindings = KeybindingsManager.create();
|
||||
setKeybindings(keybindings);
|
||||
this.#editor = new CustomEditor(this.#ui, getEditorTheme(), keybindings, { paddingX: 1 });
|
||||
this.#editor.onSubmit = handlers.submit;
|
||||
this.#editor.onEscape = handlers.interrupt;
|
||||
this.#editor.onCtrlD = handlers.exit;
|
||||
this.#editor.onAction("app.clear", handlers.exit);
|
||||
this.#editor.onAction("app.model.select", handlers.selectModel);
|
||||
this.#editor.onAction("app.message.followUp", () => {
|
||||
const text = this.#editor.getText().trim();
|
||||
if (text.length === 0) return;
|
||||
this.#editor.setText("");
|
||||
handlers.queueFollowUp(text);
|
||||
});
|
||||
|
||||
this.#editorContainer.addChild(this.#editor);
|
||||
const transcript = new ScrollView(this.#chat, { follow: "end", primary: true, overscroll: "chain" });
|
||||
const dock = new VStack([
|
||||
{ component: this.#queue, shrink: 1, minSize: 0 },
|
||||
{ component: this.#status, shrink: 1, minSize: 0 },
|
||||
{ component: this.#editorContainer, shrink: 1, minSize: 3 },
|
||||
{ component: this.#footer, shrink: 1, minSize: 0 },
|
||||
]);
|
||||
this.#ui.addChild(this.#chat);
|
||||
this.#ui.addChild(this.#queue);
|
||||
this.#ui.addChild(this.#status);
|
||||
this.#ui.addChild(this.#editorContainer);
|
||||
this.#ui.addChild(this.#footer);
|
||||
this.#ui.setLayoutRoot(
|
||||
new VStack([
|
||||
{ component: transcript, basis: 0, grow: 1, shrink: 1, minSize: 1 },
|
||||
{ component: dock, basis: "auto", grow: 0, shrink: 1, minSize: 1 },
|
||||
]),
|
||||
);
|
||||
this.#ui.setFocus(this.#editor);
|
||||
}
|
||||
|
||||
get ui(): TuiAltScreen {
|
||||
return this.#ui;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.#ui.start();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.#mountedDispose?.();
|
||||
this.#indicator?.dispose();
|
||||
this.#ui.stop();
|
||||
}
|
||||
|
||||
render(): void {
|
||||
this.#ui.requestRender();
|
||||
}
|
||||
|
||||
addText(text: string): void {
|
||||
this.#chat.addChild(new Spacer(1));
|
||||
this.#chat.addChild(new Text(text, 1, 0));
|
||||
this.render();
|
||||
}
|
||||
|
||||
setFooter(text: string): void {
|
||||
this.#footer.setText(theme.fg("dim", text));
|
||||
this.render();
|
||||
}
|
||||
|
||||
/** Swap the editor slot for a selector or dialog. */
|
||||
mount(component: Component, focus: Component, dispose?: () => void): void {
|
||||
this.#mountedDispose?.();
|
||||
this.#mountedDispose = dispose;
|
||||
this.#editorContainer.clear();
|
||||
this.#editorContainer.addChild(component);
|
||||
this.#ui.setFocus(focus);
|
||||
this.render();
|
||||
}
|
||||
|
||||
restoreEditor(): void {
|
||||
this.#mountedDispose?.();
|
||||
this.#mountedDispose = undefined;
|
||||
this.#editorContainer.clear();
|
||||
this.#editorContainer.addChild(this.#editor);
|
||||
this.#ui.setFocus(this.#editor);
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Paint one replicated snapshot. Settled entries are keyed by id and only appended, so a redraw
|
||||
* per streamed token costs one markdown rebuild, not a full transcript rebuild.
|
||||
*/
|
||||
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);
|
||||
if (tool.status === "running") {
|
||||
component.markExecutionStarted();
|
||||
if (tool.result !== undefined) component.updateResult({ ...tool.result, isError: false }, true);
|
||||
} else {
|
||||
component.updateResult({ ...tool.result, isError: tool.isError }, false);
|
||||
}
|
||||
}
|
||||
this.#syncQueues(snapshot.queues);
|
||||
this.#setWorking(snapshot.operation !== null);
|
||||
this.render();
|
||||
}
|
||||
|
||||
/** One ordered tagged inbox: steered and queued input, plus writes waiting for a boundary. */
|
||||
#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) {
|
||||
// Compaction, navigation, or a fork rewrote the branch: repaint from scratch.
|
||||
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") {
|
||||
// The compaction entry heads the branch, so it has to render its own retained messages.
|
||||
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)));
|
||||
} else if (message.role === "assistant") {
|
||||
// Adopt the streaming component so the settled message replaces it instead of duplicating it.
|
||||
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") continue;
|
||||
this.#tool(content.name, content.id, content.arguments).setArgsComplete();
|
||||
}
|
||||
} else 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);
|
||||
}
|
||||
}
|
||||
|
||||
/** Built-in renderers, without the tool implementations or their schemas. */
|
||||
static readonly #renderers: Record<string, ToolRenderers> = createAllToolRenderers();
|
||||
|
||||
/** Get or create the component for a tool call; omit `args` to look up without overwriting them. */
|
||||
#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 ?? {},
|
||||
{},
|
||||
MiniTui.#renderers[toolName],
|
||||
this.#ui,
|
||||
this.#cwd,
|
||||
);
|
||||
this.#chat.addChild(component);
|
||||
this.#tools.set(toolCallId, component);
|
||||
return component;
|
||||
}
|
||||
|
||||
#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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toAuthSelectorProviders(accounts: readonly ProviderAccount[]): AuthSelectorProvider[] {
|
||||
return accounts.map((account) => ({
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
authType: account.authType,
|
||||
...(account.configured ? { status: { type: account.authType, source: account.source ?? "configured" } } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
/** The client half of an interactive login, live only while its dialog is mounted. */
|
||||
interface LoginUi {
|
||||
prompt(request: AuthPromptRequest): Promise<string | null>;
|
||||
notify(notice: AuthEvent): void;
|
||||
}
|
||||
|
||||
function runLogin(
|
||||
view: MiniTui,
|
||||
client: AttachedSession,
|
||||
account: ProviderAccount,
|
||||
setUi: (ui: LoginUi | undefined) => void,
|
||||
): Promise<CommandResult> {
|
||||
const dialog = new LoginDialogComponent(view.ui, account.id, () => view.restoreEditor(), account.name);
|
||||
view.mount(dialog, dialog);
|
||||
if (!account.interactive) {
|
||||
dialog.showInfo(`${account.methodName ?? "Authentication"} is configured outside pi.`, [], true);
|
||||
return Promise.resolve({ ok: true });
|
||||
}
|
||||
setUi({
|
||||
notify: (notice) => {
|
||||
if (notice.type === "auth_url") dialog.showAuth(notice.url, notice.instructions);
|
||||
else if (notice.type === "device_code") {
|
||||
dialog.showDeviceCode(notice);
|
||||
dialog.showWaiting("Waiting for authentication...");
|
||||
} else if (notice.type === "info") dialog.showInfo(notice.message, notice.links);
|
||||
else dialog.showProgress(notice.message);
|
||||
},
|
||||
prompt: (request) => {
|
||||
if (request.type === "manual_code") return dialog.showManualInput(request.message);
|
||||
if (request.type !== "select") return dialog.showPrompt(request.message, request.placeholder);
|
||||
return new Promise<string | null>((resolve) => {
|
||||
const selector = new ExtensionSelectorComponent(
|
||||
request.message,
|
||||
request.options.map((option) => option.label),
|
||||
(label) => {
|
||||
view.mount(dialog, dialog);
|
||||
resolve(request.options.find((option) => option.label === label)?.id ?? null);
|
||||
},
|
||||
() => {
|
||||
view.mount(dialog, dialog);
|
||||
resolve(null);
|
||||
},
|
||||
);
|
||||
view.mount(selector, selector);
|
||||
});
|
||||
},
|
||||
});
|
||||
return client.models.login(account.id, account.authType).finally(() => {
|
||||
setUi(undefined);
|
||||
view.restoreEditor();
|
||||
});
|
||||
}
|
||||
|
||||
/** Run the view against one attached session until the user exits. */
|
||||
export async function runView(client: AttachedSession): Promise<void> {
|
||||
initTheme();
|
||||
let exit = (): void => {};
|
||||
const exited = new Promise<void>((resolve) => {
|
||||
exit = () => resolve();
|
||||
});
|
||||
|
||||
let view!: MiniTui;
|
||||
let loginUi: LoginUi | undefined;
|
||||
const report = (result: CommandResult): void => {
|
||||
if (!result.ok) view.addText(theme.fg("error", result.error));
|
||||
};
|
||||
client.onAuth((event) => {
|
||||
if (event.type === "notice") {
|
||||
loginUi?.notify(event.notice);
|
||||
return;
|
||||
}
|
||||
const answer = loginUi?.prompt(event.request) ?? Promise.resolve(null);
|
||||
void answer.then(
|
||||
(value) => client.models.authReply(event.requestId, value),
|
||||
() => client.models.authReply(event.requestId, null),
|
||||
);
|
||||
});
|
||||
|
||||
const selectModel = (): void => {
|
||||
const items: SelectItem[] = client.state().models.models.map((model) => ({
|
||||
value: `${model.provider}/${model.modelId}`,
|
||||
label: model.modelId,
|
||||
description: model.provider,
|
||||
}));
|
||||
const selector = new ListSelector(
|
||||
"Select model:",
|
||||
items,
|
||||
(value) => {
|
||||
view.restoreEditor();
|
||||
const separator = value.indexOf("/");
|
||||
void client.lane
|
||||
.setModel({ provider: value.slice(0, separator), modelId: value.slice(separator + 1) })
|
||||
.then(report);
|
||||
},
|
||||
() => view.restoreEditor(),
|
||||
);
|
||||
view.mount(selector, selector);
|
||||
};
|
||||
const selectLoginProvider = (authType: ProviderAccount["authType"]): void => {
|
||||
const accounts = client.state().models.accounts.filter((account) => account.authType === authType);
|
||||
if (accounts.length === 0) {
|
||||
view.restoreEditor();
|
||||
view.addText(theme.fg("dim", "No providers for that method."));
|
||||
return;
|
||||
}
|
||||
const selector = new OAuthSelectorComponent(
|
||||
"login",
|
||||
toAuthSelectorProviders(accounts),
|
||||
(providerId) => {
|
||||
view.restoreEditor();
|
||||
const account = accounts.find((candidate) => candidate.id === providerId);
|
||||
const setLoginUi = (ui: LoginUi | undefined): void => {
|
||||
loginUi = ui;
|
||||
};
|
||||
if (account) void runLogin(view, client, account, setLoginUi).then(report);
|
||||
},
|
||||
// Cancelling the provider list steps back to the auth method choice, as in interactive mode.
|
||||
() => login(),
|
||||
);
|
||||
view.mount(selector, selector);
|
||||
};
|
||||
const login = (): void => {
|
||||
const selector = new ExtensionSelectorComponent(
|
||||
"Select authentication method:",
|
||||
[SUBSCRIPTION_LOGIN_LABEL, API_KEY_LOGIN_LABEL],
|
||||
(option) => selectLoginProvider(option === SUBSCRIPTION_LOGIN_LABEL ? "oauth" : "api_key"),
|
||||
() => view.restoreEditor(),
|
||||
);
|
||||
view.mount(selector, selector);
|
||||
};
|
||||
|
||||
view = new MiniTui(client.state().cwd, {
|
||||
submit: (text) => {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.length === 0) return;
|
||||
if (trimmed === "/model") return selectModel();
|
||||
if (trimmed === "/login") return login();
|
||||
if (trimmed === "/compact") {
|
||||
void client.lane.compact().then(report);
|
||||
return;
|
||||
}
|
||||
// A submission during an active run steers it; alt+enter queues a follow-up instead.
|
||||
const busy = client.state().lane.operation !== null;
|
||||
void (busy ? client.lane.steer(trimmed) : client.lane.prompt(trimmed)).then(report);
|
||||
},
|
||||
queueFollowUp: (text) => void client.lane.followUp(text).then(report),
|
||||
// The worker is authoritative. Never suppress abort from a potentially stale presentation snapshot.
|
||||
interrupt: () => void client.lane.abort().then(report),
|
||||
exit,
|
||||
selectModel: () => selectModel(),
|
||||
});
|
||||
|
||||
const render = (): void => {
|
||||
const snapshot = client.state();
|
||||
view.apply(snapshot.lane);
|
||||
const { model, thinkingLevel } = snapshot.lane.configuration;
|
||||
view.setFooter(
|
||||
`${model.provider}/${model.modelId} · thinking:${thinkingLevel} · ${keyText("app.model.select")} or /model · /login · /compact · ${keyText("app.message.followUp")} follow-up · ${keyText("app.clear")} exit`,
|
||||
);
|
||||
};
|
||||
const unsubscribe = client.subscribe(render);
|
||||
|
||||
view.start();
|
||||
render();
|
||||
view.addText(theme.fg("dim", client.state().sessionPath));
|
||||
await exited;
|
||||
unsubscribe();
|
||||
view.stop();
|
||||
}
|
||||
13
third_party/pi/packages/coding-agent/src/experimental/mini/worker/entry.ts
vendored
Normal file
13
third_party/pi/packages/coding-agent/src/experimental/mini/worker/entry.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
/** Session worker process entry: `node worker/entry.ts <sessionsRoot> <cwd> [sessionId]`. */
|
||||
|
||||
import { runSessionWorker } from "./run.ts";
|
||||
|
||||
const [sessionsRoot, cwd, sessionId] = process.argv.slice(2);
|
||||
if (!sessionsRoot || !cwd) throw new Error("Session worker requires <sessionsRoot> <cwd> [sessionId]");
|
||||
|
||||
void runSessionWorker({ sessionsRoot, cwd, ...(sessionId === undefined ? {} : { sessionId }) }).catch(
|
||||
(error: unknown) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
},
|
||||
);
|
||||
126
third_party/pi/packages/coding-agent/src/experimental/mini/worker/lane-service.ts
vendored
Normal file
126
third_party/pi/packages/coding-agent/src/experimental/mini/worker/lane-service.ts
vendored
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Worker-side implementation of the `Lane` service.
|
||||
*
|
||||
* The lane, harness, and model registry stay here. Each presentation gets its own `lane.watch()`,
|
||||
* whose snapshot and event stream the harness already pairs with no gap and no duplicate, so nothing
|
||||
* here re-implements that alignment: a subscription is just a watch handle plus an id.
|
||||
*/
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { AgentLane, Context, HarnessEvent, LaneSnapshot, WatchHandle } from "@earendil-works/pi-agent-core";
|
||||
import type { Models } from "@earendil-works/pi-ai";
|
||||
import type {
|
||||
CommandResult,
|
||||
LaneServiceApi,
|
||||
LaneSubscription,
|
||||
ModelRef,
|
||||
ModelsState,
|
||||
SessionSnapshot,
|
||||
} from "../shared/protocol.ts";
|
||||
|
||||
export interface LaneServiceOptions {
|
||||
lane: AgentLane;
|
||||
models: Models;
|
||||
context: Context;
|
||||
session: { id: string; cwd: string; path: string };
|
||||
/** Model catalog state belongs to the `Models` service; the snapshot carries a copy. */
|
||||
modelsState: () => ModelsState;
|
||||
publish: (subscriptionId: string, to: string, event: HarnessEvent) => void;
|
||||
}
|
||||
|
||||
export class LaneService implements LaneServiceApi {
|
||||
readonly #options: LaneServiceOptions;
|
||||
readonly #watches = new Map<string, { handle: WatchHandle<LaneSnapshot>; to: string }>();
|
||||
|
||||
constructor(options: LaneServiceOptions) {
|
||||
this.#options = options;
|
||||
}
|
||||
|
||||
/** Capture a snapshot. The harness buffers this subscription's events until `start`. */
|
||||
async watch(presentationId: string): Promise<LaneSubscription> {
|
||||
const { lane, context, session } = this.#options;
|
||||
const subscriptionId = randomUUID();
|
||||
const handle = await lane.watch(context);
|
||||
try {
|
||||
this.#watches.set(subscriptionId, { handle, to: presentationId });
|
||||
const snapshot: SessionSnapshot = {
|
||||
sessionId: session.id,
|
||||
cwd: session.cwd,
|
||||
sessionPath: session.path,
|
||||
lane: handle.snapshot,
|
||||
models: this.#options.modelsState(),
|
||||
};
|
||||
return { subscriptionId, snapshot };
|
||||
} catch (error) {
|
||||
// A watcher that is never started buffers without bound.
|
||||
handle.unsubscribe();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Begin delivery, draining what buffered since the snapshot. */
|
||||
async start(subscriptionId: string): Promise<void> {
|
||||
const watch = this.#watches.get(subscriptionId);
|
||||
if (!watch) throw new Error(`Unknown subscription: ${subscriptionId}`);
|
||||
watch.handle.start((event) => this.#options.publish(subscriptionId, watch.to, event));
|
||||
}
|
||||
|
||||
async unwatch(subscriptionId: string): Promise<void> {
|
||||
this.#watches.get(subscriptionId)?.handle.unsubscribe();
|
||||
this.#watches.delete(subscriptionId);
|
||||
}
|
||||
|
||||
prompt(text: string): Promise<CommandResult> {
|
||||
return this.#command(() => this.#options.lane.prompt(text, undefined, this.#options.context));
|
||||
}
|
||||
|
||||
steer(text: string): Promise<CommandResult> {
|
||||
return this.#command(() => this.#options.lane.steer(text, undefined, this.#options.context));
|
||||
}
|
||||
|
||||
followUp(text: string): Promise<CommandResult> {
|
||||
return this.#command(() => this.#options.lane.followUp(text, undefined, this.#options.context));
|
||||
}
|
||||
|
||||
compact(): Promise<CommandResult> {
|
||||
return this.#command(() => this.#options.lane.compact(undefined, this.#options.context));
|
||||
}
|
||||
|
||||
abort(): Promise<CommandResult> {
|
||||
return this.#command(() => this.#options.lane.abort(this.#options.context));
|
||||
}
|
||||
|
||||
/**
|
||||
* The lane stores a durable identity, so the ref passes straight through. The registry lookup is
|
||||
* only a courtesy: an identity this worker cannot serve fails at generation time otherwise.
|
||||
*/
|
||||
async setModel(ref: ModelRef): Promise<CommandResult> {
|
||||
if (!this.#options.models.getModel(ref.provider, ref.modelId)) {
|
||||
return { ok: false, error: `Unknown model: ${ref.provider}/${ref.modelId}` };
|
||||
}
|
||||
try {
|
||||
await this.#options.lane.setModel(ref, this.#options.context);
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return { ok: false, error: message(error) };
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
for (const watch of this.#watches.values()) watch.handle.unsubscribe();
|
||||
this.#watches.clear();
|
||||
}
|
||||
|
||||
async #command(run: () => Promise<{ ok: boolean; error?: { message: string } }>): Promise<CommandResult> {
|
||||
try {
|
||||
const result = await run();
|
||||
return result.ok ? { ok: true } : { ok: false, error: result.error?.message ?? "Command failed" };
|
||||
} catch (error) {
|
||||
return { ok: false, error: message(error) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function message(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
148
third_party/pi/packages/coding-agent/src/experimental/mini/worker/models-service.ts
vendored
Normal file
148
third_party/pi/packages/coding-agent/src/experimental/mini/worker/models-service.ts
vendored
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Worker-side implementation of the `Models` service.
|
||||
*
|
||||
* `ModelRuntime`, `Model`, and provider objects stay here. What leaves is a serializable catalog and
|
||||
* account list, plus login prompts and notices as data.
|
||||
*/
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { ModelRuntime } from "../../../core/model-runtime.ts";
|
||||
import { refreshModelCatalogs } from "../../../modes/interactive/model-catalog-refresh.ts";
|
||||
import type {
|
||||
AuthPromptRequest,
|
||||
CommandResult,
|
||||
ModelsEvent,
|
||||
ModelsServiceApi,
|
||||
ModelsState,
|
||||
ProviderAccount,
|
||||
} from "../shared/protocol.ts";
|
||||
|
||||
const CATALOG_REFRESH_TIMEOUT_MS = 15_000;
|
||||
|
||||
export class ModelsService implements ModelsServiceApi {
|
||||
readonly #runtime: ModelRuntime;
|
||||
readonly #publish: (event: ModelsEvent) => void;
|
||||
/** Prompts issued to the presentation, awaiting `authReply`. */
|
||||
readonly #pendingAuth = new Map<string, (answer: string | null) => void>();
|
||||
#state: ModelsState;
|
||||
|
||||
constructor(runtime: ModelRuntime, publish: (event: ModelsEvent) => void) {
|
||||
this.#runtime = runtime;
|
||||
this.#publish = publish;
|
||||
this.#state = readState(runtime, false);
|
||||
}
|
||||
|
||||
get state(): ModelsState {
|
||||
return this.#state;
|
||||
}
|
||||
|
||||
async refresh(): Promise<CommandResult> {
|
||||
this.#update(true);
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), CATALOG_REFRESH_TIMEOUT_MS);
|
||||
try {
|
||||
const result = await refreshModelCatalogs(this.#runtime, controller.signal);
|
||||
return result.errors.size === 0
|
||||
? { ok: true }
|
||||
: { ok: false, error: `Some catalogs could not be refreshed: ${[...result.errors.keys()].join(", ")}` };
|
||||
} catch (error) {
|
||||
return { ok: false, error: message(error) };
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
this.#update(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** Prompts and notices travel to the presentation as events; answers come back via `authReply`. */
|
||||
async login(providerId: string, authType: ProviderAccount["authType"]): Promise<CommandResult> {
|
||||
try {
|
||||
await this.#runtime.login(providerId, authType, {
|
||||
prompt: (prompt) => {
|
||||
const { signal, ...request } = prompt;
|
||||
return this.#ask(request, signal);
|
||||
},
|
||||
notify: (notice) => this.#publish({ type: "notice", notice }),
|
||||
});
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return { ok: false, error: message(error) };
|
||||
} finally {
|
||||
this.#update(false);
|
||||
}
|
||||
}
|
||||
|
||||
async authReply(requestId: string, answer: string | null): Promise<void> {
|
||||
const waiter = this.#pendingAuth.get(requestId);
|
||||
this.#pendingAuth.delete(requestId);
|
||||
waiter?.(answer);
|
||||
}
|
||||
|
||||
/** Ask the presentation one question, honouring a provider-supplied deadline. */
|
||||
#ask(request: AuthPromptRequest, signal: AbortSignal | undefined): Promise<string> {
|
||||
if (signal?.aborted) return Promise.reject(new Error("Login cancelled"));
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const requestId = randomUUID();
|
||||
const settle = (answer: string | null): void => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
if (answer === null) reject(new Error("Login cancelled"));
|
||||
else resolve(answer);
|
||||
};
|
||||
const onAbort = (): void => {
|
||||
this.#pendingAuth.delete(requestId);
|
||||
reject(new Error("Login cancelled"));
|
||||
};
|
||||
this.#pendingAuth.set(requestId, settle);
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
this.#publish({ type: "prompt", requestId, request });
|
||||
});
|
||||
}
|
||||
|
||||
async logout(providerId: string): Promise<CommandResult> {
|
||||
try {
|
||||
await this.#runtime.logout(providerId);
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return { ok: false, error: message(error) };
|
||||
} finally {
|
||||
this.#update(false);
|
||||
}
|
||||
}
|
||||
|
||||
#update(refreshing: boolean): void {
|
||||
this.#state = readState(this.#runtime, refreshing);
|
||||
this.#publish({ type: "state", state: this.#state });
|
||||
}
|
||||
}
|
||||
|
||||
function readState(runtime: ModelRuntime, refreshing: boolean): ModelsState {
|
||||
const models = runtime
|
||||
.getAvailableSnapshot()
|
||||
.map((model) => ({ provider: model.provider, modelId: model.id, name: model.name }));
|
||||
const accounts: ProviderAccount[] = [];
|
||||
for (const provider of runtime.getProviders()) {
|
||||
const status = runtime.getProviderAuthStatus(provider.id);
|
||||
const shared = {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
configured: status.configured,
|
||||
...((status.label ?? status.source === undefined) ? {} : { source: status.label ?? status.source }),
|
||||
};
|
||||
if (provider.auth.oauth) {
|
||||
accounts.push({ ...shared, authType: "oauth", interactive: true, methodName: provider.auth.oauth.name });
|
||||
}
|
||||
if (provider.auth.apiKey) {
|
||||
accounts.push({
|
||||
...shared,
|
||||
authType: "api_key",
|
||||
interactive: provider.auth.apiKey.login !== undefined,
|
||||
methodName: provider.auth.apiKey.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
accounts.sort((left, right) => left.name.localeCompare(right.name));
|
||||
return { models, accounts, refreshing };
|
||||
}
|
||||
|
||||
function message(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
113
third_party/pi/packages/coding-agent/src/experimental/mini/worker/run.ts
vendored
Normal file
113
third_party/pi/packages/coding-agent/src/experimental/mini/worker/run.ts
vendored
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Session worker: one process per session.
|
||||
*
|
||||
* It owns every live object — storage, harness, lane, model runtime — and publishes them only as the
|
||||
* `Lane` and `Models` services. It speaks JSON over its stdio pipes to the server that spawned it,
|
||||
* and can call server services (`Sessions`) over the same peer.
|
||||
*/
|
||||
|
||||
import {
|
||||
AgentHarness,
|
||||
BACKGROUND_CONTEXT,
|
||||
type Context,
|
||||
createBashTool,
|
||||
createEditTool,
|
||||
createReadTool,
|
||||
createWriteTool,
|
||||
type JsonlSessionMetadata,
|
||||
JsonlSessionRepo,
|
||||
type Session,
|
||||
} from "@earendil-works/pi-agent-core";
|
||||
import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
|
||||
import { findInitialModel } from "../../../core/model-resolver.ts";
|
||||
import { ModelRuntime } from "../../../core/model-runtime.ts";
|
||||
import { Lane, Models, Worker } from "../shared/protocol.ts";
|
||||
import { createPeer } from "../shared/rpc.ts";
|
||||
import { parentConnection } from "../shared/transport.ts";
|
||||
import { LaneService } from "./lane-service.ts";
|
||||
import { ModelsService } from "./models-service.ts";
|
||||
|
||||
function systemPrompt(cwd: string): string {
|
||||
return [
|
||||
"You are a coding agent working in a terminal.",
|
||||
`Working directory: ${cwd}`,
|
||||
"Use the read, write, edit, and bash tools to inspect and change files.",
|
||||
"Keep answers short and technical.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function openSession(
|
||||
repo: JsonlSessionRepo,
|
||||
sessionId: string | undefined,
|
||||
cwd: string,
|
||||
context: Context,
|
||||
): Promise<Session<JsonlSessionMetadata>> {
|
||||
if (sessionId === undefined) return repo.create({ cwd }, context);
|
||||
const metadata = (await repo.list(undefined, context)).find((candidate) => candidate.id === sessionId);
|
||||
if (!metadata) throw new Error(`Unknown session: ${sessionId}`);
|
||||
return repo.open(metadata, context);
|
||||
}
|
||||
|
||||
/** Run one session worker until its stdio closes. `sessionId` undefined creates a new session. */
|
||||
export async function runSessionWorker(options: {
|
||||
sessionsRoot: string;
|
||||
sessionId?: string;
|
||||
cwd: string;
|
||||
}): Promise<void> {
|
||||
const context = BACKGROUND_CONTEXT;
|
||||
const { cwd } = options;
|
||||
const modelRuntime = await ModelRuntime.create();
|
||||
const { model, thinkingLevel } = await findInitialModel({ scopedModels: [], isContinuing: false, modelRuntime });
|
||||
if (!model) throw new Error("No model available. Configure credentials with `pi` first.");
|
||||
|
||||
const executionEnv = new NodeExecutionEnv({ cwd });
|
||||
const repo = new JsonlSessionRepo({ fileSystem: executionEnv, sessionsRoot: options.sessionsRoot });
|
||||
const session = await openSession(repo, options.sessionId, cwd, context);
|
||||
const { harness, open } = await AgentHarness.create(
|
||||
{
|
||||
session,
|
||||
models: modelRuntime,
|
||||
model,
|
||||
thinkingLevel,
|
||||
tools: [createReadTool(), createWriteTool(), createEditTool(), createBashTool()],
|
||||
toolContext: { env: executionEnv },
|
||||
systemPrompt: systemPrompt(cwd),
|
||||
},
|
||||
context,
|
||||
);
|
||||
const lane = await harness.lane("main", context);
|
||||
const connection = parentConnection();
|
||||
const peer = createPeer(connection);
|
||||
|
||||
const models = new ModelsService(modelRuntime, (event) => peer.emit(Models, event));
|
||||
const laneService = new LaneService({
|
||||
lane,
|
||||
models: modelRuntime,
|
||||
context,
|
||||
session: { id: session.metadata.id, cwd, path: session.metadata.path },
|
||||
modelsState: () => models.state,
|
||||
publish: (subscriptionId, to, event) => peer.emitTo(Lane, { subscriptionId, event }, to),
|
||||
});
|
||||
peer.provide(Lane, laneService);
|
||||
peer.provide(Models, models);
|
||||
peer.provide(Worker, { describe: async () => ({ sessionId: session.metadata.id }) });
|
||||
|
||||
// Creation restores durable operation state without starting effects. Once services are reachable,
|
||||
// install a new process-local drive for every operation left open by the previous worker.
|
||||
const recoveries = open.map(async (operation) => {
|
||||
try {
|
||||
const restoredLane = operation.lane === lane.name ? lane : await harness.lane(operation.lane, context);
|
||||
const result = await restoredLane.resume(context);
|
||||
if (!result.ok) throw result.error;
|
||||
} catch (error) {
|
||||
console.error(`Failed to resume ${operation.lane}/${operation.operationId}:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => connection.onClose(resolve));
|
||||
laneService.close();
|
||||
await harness.close(context).catch(() => {});
|
||||
await Promise.all(recoveries);
|
||||
await repo.close(context).catch(() => {});
|
||||
await executionEnv.cleanup(context).catch(() => {});
|
||||
}
|
||||
16
third_party/pi/packages/coding-agent/src/experimental/plugin.ts
vendored
Normal file
16
third_party/pi/packages/coding-agent/src/experimental/plugin.ts
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
export {
|
||||
type AgentCompactionRequest,
|
||||
AgentController,
|
||||
type AgentNavigationRequest,
|
||||
type AgentOperationError,
|
||||
type AgentOperationResponse,
|
||||
type AgentPromptRequest,
|
||||
type AgentQueueResponse,
|
||||
} from "./services/agent-controller.ts";
|
||||
export { type PresentationSelectItem, PresentationUI } from "./services/presentation-ui.ts";
|
||||
export {
|
||||
type SlashCommandCompletion,
|
||||
type SlashCommandContribution,
|
||||
type SlashCommandRunResult,
|
||||
SlashCommands,
|
||||
} from "./services/slash-commands.ts";
|
||||
55
third_party/pi/packages/coding-agent/src/experimental/plugins/bundled.ts
vendored
Normal file
55
third_party/pi/packages/coding-agent/src/experimental/plugins/bundled.ts
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
import { combineFacetLoaders, type FacetLoader, type JsonValue } from "@earendil-works/chord";
|
||||
import {
|
||||
createFacetBundleArtifactLoader,
|
||||
createFacetBundleLoader,
|
||||
type FacetBundleArtifact,
|
||||
readFacetBundleManifest,
|
||||
} from "@earendil-works/chord/node";
|
||||
|
||||
const PRESENTATION_FACET_BUNDLES_KEY = "presentationFacetBundles";
|
||||
const PI_PLUGIN_API = "@earendil-works/pi-coding-agent/experimental/plugin";
|
||||
|
||||
export function createSessionPluginFacetLoader(manifestPaths: readonly string[]): FacetLoader | undefined {
|
||||
if (manifestPaths.length === 0) return undefined;
|
||||
return combineFacetLoaders(manifestPaths.map(createOptionalSessionFacetLoader));
|
||||
}
|
||||
|
||||
function createOptionalSessionFacetLoader(manifestPath: string): FacetLoader {
|
||||
const loader = createFacetBundleLoader({
|
||||
manifestPath,
|
||||
entry: "session",
|
||||
resolveExternal: resolvePluginExternal,
|
||||
});
|
||||
return {
|
||||
async load() {
|
||||
const manifest = await readFacetBundleManifest(manifestPath);
|
||||
if (manifest.entries.session !== undefined) return loader.load();
|
||||
return { facets: Object.freeze([]), async dispose() {} };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createPresentationFacetData(artifacts: readonly FacetBundleArtifact[]): JsonValue {
|
||||
return {
|
||||
[PRESENTATION_FACET_BUNDLES_KEY]: artifacts.map((artifact) => artifact as unknown as JsonValue),
|
||||
};
|
||||
}
|
||||
|
||||
/** Create local loaders only from artifacts selected and sent by the connected server. */
|
||||
export function createPresentationFacetLoaders(data: JsonValue): readonly FacetLoader[] {
|
||||
if (data === null || Array.isArray(data) || typeof data !== "object") {
|
||||
throw new Error("Invalid presentation plugin data");
|
||||
}
|
||||
const artifacts = data[PRESENTATION_FACET_BUNDLES_KEY];
|
||||
if (artifacts === undefined) return [];
|
||||
if (!Array.isArray(artifacts)) throw new Error("Invalid presentation plugin bundle list");
|
||||
return artifacts.map((artifact) =>
|
||||
createFacetBundleArtifactLoader({ artifact, resolveExternal: resolvePluginExternal }),
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePluginExternal(specifier: string): string | undefined {
|
||||
if (specifier !== PI_PLUGIN_API) return undefined;
|
||||
const extension = import.meta.url.endsWith(".ts") ? "ts" : "js";
|
||||
return new URL(`../plugin.${extension}`, import.meta.url).href;
|
||||
}
|
||||
171
third_party/pi/packages/coding-agent/src/experimental/plugins/package.ts
vendored
Normal file
171
third_party/pi/packages/coding-agent/src/experimental/plugins/package.ts
vendored
Normal file
@@ -0,0 +1,171 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
import { bundleFacetPackage } from "@earendil-works/chord/bundler";
|
||||
import {
|
||||
FACET_BUNDLE_MANIFEST_FILE,
|
||||
type FacetBundleArtifact,
|
||||
readFacetBundleArtifact,
|
||||
} from "@earendil-works/chord/node";
|
||||
import type { ServerId } from "@earendil-works/pi-protocol";
|
||||
|
||||
const PLUGIN_PACKAGE_PROFILE_VERSION = 1;
|
||||
const DEFAULT_PLUGIN_FACETS = Object.freeze({ session: "src/session.ts", tui: "src/tui.ts" });
|
||||
|
||||
export interface ConfiguredServerPluginPackage {
|
||||
readonly manifestPath: string;
|
||||
build(): Promise<readonly FacetBundleArtifact[]>;
|
||||
}
|
||||
|
||||
/** Persist an explicit plugin package selection or restore it for a later server generation. */
|
||||
export async function restoreServerPluginPackageProfile(
|
||||
directory: string,
|
||||
serverId: ServerId,
|
||||
configuredPackagePaths?: readonly string[],
|
||||
): Promise<readonly string[]> {
|
||||
const path = join(directory, `plugin-packages-${serverId}.json`);
|
||||
if (configuredPackagePaths === undefined) {
|
||||
return (await readPluginPackageProfile(path, false)) ?? [];
|
||||
}
|
||||
const packagePaths = normalizePluginPackagePaths(configuredPackagePaths);
|
||||
if (packagePaths.length === 0) await rm(path, { force: true });
|
||||
else await writePluginPackageProfile(path, packagePaths);
|
||||
return packagePaths;
|
||||
}
|
||||
|
||||
/** Read the explicit plugin selection stored for one durable Session. */
|
||||
export function readSessionPluginPackageProfile(
|
||||
directory: string,
|
||||
serverId: ServerId,
|
||||
sessionPath: string,
|
||||
): Promise<readonly string[] | undefined> {
|
||||
return readPluginPackageProfile(sessionPluginProfilePath(directory, serverId, sessionPath), true, sessionPath);
|
||||
}
|
||||
|
||||
/** Remove the plugin selection stored for one deleted Session. */
|
||||
export async function removeSessionPluginPackageProfile(
|
||||
directory: string,
|
||||
serverId: ServerId,
|
||||
sessionPath: string,
|
||||
): Promise<void> {
|
||||
await rm(sessionPluginProfilePath(directory, serverId, sessionPath), { force: true });
|
||||
}
|
||||
|
||||
/** Persist the plugin selection for one durable Session. */
|
||||
export function writeSessionPluginPackageProfile(
|
||||
directory: string,
|
||||
serverId: ServerId,
|
||||
sessionPath: string,
|
||||
packagePaths: readonly string[],
|
||||
): Promise<void> {
|
||||
return writePluginPackageProfile(
|
||||
sessionPluginProfilePath(directory, serverId, sessionPath),
|
||||
normalizePluginPackagePaths(packagePaths),
|
||||
sessionPath,
|
||||
);
|
||||
}
|
||||
|
||||
/** Create a serialized server-owned builder for one configured plugin package. */
|
||||
export function createServerPluginPackage(
|
||||
directory: string,
|
||||
serverId: ServerId,
|
||||
packagePath: string,
|
||||
): ConfiguredServerPluginPackage {
|
||||
const normalizedPackagePath = resolve(packagePath);
|
||||
const outdir = join(directory, "plugin-builds", serverId, pluginBuildDirectoryName(normalizedPackagePath));
|
||||
const manifestPath = join(outdir, FACET_BUNDLE_MANIFEST_FILE);
|
||||
let buildTail = Promise.resolve();
|
||||
return {
|
||||
manifestPath,
|
||||
build() {
|
||||
const operation = buildTail.then(async () => {
|
||||
const result = await bundleFacetPackage({
|
||||
packagePath: normalizedPackagePath,
|
||||
outdir,
|
||||
defaultFacets: DEFAULT_PLUGIN_FACETS,
|
||||
});
|
||||
if (result.manifest.entries.tui === undefined) return [];
|
||||
return [await readFacetBundleArtifact({ manifestPath: result.manifestPath, entry: "tui" })];
|
||||
});
|
||||
buildTail = operation.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return operation;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function readPluginPackageProfile(
|
||||
path: string,
|
||||
allowEmpty: boolean,
|
||||
sessionPath?: string,
|
||||
): Promise<readonly string[] | undefined> {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(await readFile(path, "utf8"));
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "code" in error && error.code === "ENOENT") return undefined;
|
||||
throw new Error(`Could not read experimental plugin package profile ${path}`, { cause: error });
|
||||
}
|
||||
if (
|
||||
typeof parsed !== "object" ||
|
||||
parsed === null ||
|
||||
Array.isArray(parsed) ||
|
||||
Object.keys(parsed).some(
|
||||
(key) => key !== "version" && key !== "packagePaths" && !(key === "sessionPath" && sessionPath !== undefined),
|
||||
) ||
|
||||
!("version" in parsed) ||
|
||||
parsed.version !== PLUGIN_PACKAGE_PROFILE_VERSION ||
|
||||
(sessionPath === undefined
|
||||
? "sessionPath" in parsed
|
||||
: !("sessionPath" in parsed) || parsed.sessionPath !== sessionPath) ||
|
||||
!("packagePaths" in parsed) ||
|
||||
!Array.isArray(parsed.packagePaths) ||
|
||||
(!allowEmpty && parsed.packagePaths.length === 0) ||
|
||||
parsed.packagePaths.some((packagePath) => typeof packagePath !== "string" || packagePath.length === 0)
|
||||
) {
|
||||
throw new Error(`Invalid experimental plugin package profile ${path}`);
|
||||
}
|
||||
return normalizePluginPackagePaths(parsed.packagePaths as readonly string[]);
|
||||
}
|
||||
|
||||
function writePluginPackageProfile(path: string, packagePaths: readonly string[], sessionPath?: string): Promise<void> {
|
||||
return writeFile(
|
||||
path,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version: PLUGIN_PACKAGE_PROFILE_VERSION,
|
||||
...(sessionPath === undefined ? {} : { sessionPath }),
|
||||
packagePaths,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
{ encoding: "utf8", mode: 0o600 },
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizePluginPackagePaths(packagePaths: readonly string[]): readonly string[] {
|
||||
const normalized = packagePaths.map((packagePath) => {
|
||||
if (packagePath.length === 0) throw new Error("Plugin package path must not be empty");
|
||||
return resolve(packagePath);
|
||||
});
|
||||
if (new Set(normalized).size !== normalized.length) {
|
||||
throw new Error("Plugin package paths must be unique");
|
||||
}
|
||||
return Object.freeze(normalized);
|
||||
}
|
||||
|
||||
function sessionPluginProfilePath(directory: string, serverId: ServerId, sessionPath: string): string {
|
||||
const hash = createHash("sha256").update(sessionPath).digest("hex").slice(0, 24);
|
||||
return join(directory, `session-plugin-packages-${serverId}-${hash}.json`);
|
||||
}
|
||||
|
||||
function pluginBuildDirectoryName(packagePath: string): string {
|
||||
const packageDirectory = basename(packagePath) === "package.json" ? dirname(packagePath) : packagePath;
|
||||
const base = basename(packageDirectory).replaceAll(/[^a-zA-Z0-9._-]/gu, "-") || "plugin";
|
||||
const label = base.endsWith("-plugin") ? base : `${base}-plugin`;
|
||||
const hash = createHash("sha256").update(packagePath).digest("hex").slice(0, 12);
|
||||
return `${label}-${hash}`;
|
||||
}
|
||||
105
third_party/pi/packages/coding-agent/src/experimental/process.ts
vendored
Normal file
105
third_party/pi/packages/coding-agent/src/experimental/process.ts
vendored
Normal file
@@ -0,0 +1,105 @@
|
||||
import { type ChildProcess, spawn } from "node:child_process";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { getPackageDir, isBunBinary, isBundledNode } from "../config.ts";
|
||||
|
||||
export const INTERNAL_PROCESS_ENV = "__PI_INTERNAL_SPAWN";
|
||||
|
||||
export type InternalProcessRole = "coordinator" | "server" | "session-worker";
|
||||
|
||||
/** Detect a directly executed source or unbundled internal-process module. */
|
||||
export function isDirectInternalProcessEntry(moduleUrl: string): boolean {
|
||||
return (
|
||||
!isBunBinary &&
|
||||
!isBundledNode &&
|
||||
process.argv[1] !== undefined &&
|
||||
resolve(process.argv[1]) === fileURLToPath(moduleUrl)
|
||||
);
|
||||
}
|
||||
|
||||
/** Read and validate an internal process role without consuming it. */
|
||||
export function getInternalProcessRole(): InternalProcessRole | undefined {
|
||||
const role = process.env[INTERNAL_PROCESS_ENV];
|
||||
if (role === undefined) return undefined;
|
||||
if (role === "coordinator" || role === "server" || role === "session-worker") return role;
|
||||
throw new Error(`Unsupported internal process role: ${role}`);
|
||||
}
|
||||
|
||||
/** Read, validate, and remove the role so descendants do not inherit it. */
|
||||
export function consumeInternalProcessRole(): InternalProcessRole | undefined {
|
||||
const role = getInternalProcessRole();
|
||||
delete process.env[INTERNAL_PROCESS_ENV];
|
||||
return role;
|
||||
}
|
||||
|
||||
export interface InternalProcessSpawnOptions {
|
||||
readonly entryUrl?: URL;
|
||||
readonly env?: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
/** Spawn a detached Pi-owned process consistently across Node and compiled Bun. */
|
||||
export function spawnInternalProcess(
|
||||
role: InternalProcessRole,
|
||||
args: readonly string[],
|
||||
options: InternalProcessSpawnOptions = {},
|
||||
): ChildProcess {
|
||||
if (isBunBinary && options.entryUrl) {
|
||||
throw new Error("A compiled Bun executable cannot launch an external internal-process entrypoint");
|
||||
}
|
||||
const entryUrl = defaultEntryUrl(role, options.entryUrl);
|
||||
const sourceRuntimeArgs = import.meta.url.endsWith(".ts")
|
||||
? ["--import", fileURLToPath(new URL("source-resolver.ts", import.meta.url))]
|
||||
: [];
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
isBunBinary ? [...args] : [...sourceRuntimeArgs, fileURLToPath(entryUrl), ...args],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
detached: true,
|
||||
env: {
|
||||
...process.env,
|
||||
...options.env,
|
||||
[INTERNAL_PROCESS_ENV]: role,
|
||||
},
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
child.unref();
|
||||
return child;
|
||||
}
|
||||
|
||||
/** Force a spawned internal process to exit and wait until it can no longer take ownership. */
|
||||
export async function terminateInternalProcess(child: ChildProcess): Promise<void> {
|
||||
if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null) return;
|
||||
const terminated = new Promise<void>((resolve) => {
|
||||
child.once("exit", () => resolve());
|
||||
child.once("error", () => resolve());
|
||||
});
|
||||
child.kill("SIGKILL");
|
||||
await terminated;
|
||||
}
|
||||
|
||||
function defaultEntryUrl(role: InternalProcessRole, override: URL | undefined): URL {
|
||||
if (override) return override;
|
||||
if (isBundledNode) {
|
||||
const entry = role === "coordinator" ? "coordinator.js" : "cli.js";
|
||||
return pathToFileURL(join(getPackageDir(), "dist", "bundle", entry));
|
||||
}
|
||||
const javaScript = import.meta.url.endsWith(".js");
|
||||
if (role === "coordinator") {
|
||||
return new URL(javaScript ? "coordinator.js" : "coordinator.ts", import.meta.url);
|
||||
}
|
||||
if (role === "server") {
|
||||
return new URL(javaScript ? "server.js" : "server.ts", import.meta.url);
|
||||
}
|
||||
return new URL(javaScript ? "session-worker.js" : "session-worker.ts", import.meta.url);
|
||||
}
|
||||
|
||||
export const MAX_CONTROL_LINE_BYTES = 128 * 1024 * 1024;
|
||||
|
||||
export function encodeControlLine(message: unknown): string {
|
||||
const line = `${JSON.stringify(message)}\n`;
|
||||
if (Buffer.byteLength(line) > MAX_CONTROL_LINE_BYTES) throw new Error("Internal control message is too large");
|
||||
return line;
|
||||
}
|
||||
66
third_party/pi/packages/coding-agent/src/experimental/radius-auth.ts
vendored
Normal file
66
third_party/pi/packages/coding-agent/src/experimental/radius-auth.ts
vendored
Normal file
@@ -0,0 +1,66 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { DEFAULT_RADIUS_GATEWAY, normalizeRadiusGatewayUrl } from "@earendil-works/pi-ai/providers/radius-config";
|
||||
import { getAuthCredential } from "../cli/auth-command.ts";
|
||||
import type { AuthInput } from "../cli/experimental/command-options.ts";
|
||||
import { ModelRuntime } from "../core/model-runtime.ts";
|
||||
import { resolvePath } from "../utils/paths.ts";
|
||||
|
||||
export const ENV_RADIUS_GATEWAY = "PI_RADIUS_GATEWAY";
|
||||
|
||||
export interface RadiusRelayAuth {
|
||||
readonly gateway: string;
|
||||
readonly token: string;
|
||||
}
|
||||
|
||||
/** Resolve explicit or stored Radius credentials anew for every relay connection attempt. */
|
||||
export class RadiusRelayAuthResolver {
|
||||
readonly #input: AuthInput | undefined;
|
||||
readonly #gateway: string;
|
||||
#modelRuntime: Promise<ModelRuntime> | undefined;
|
||||
|
||||
constructor(input?: AuthInput, gateway = process.env[ENV_RADIUS_GATEWAY] ?? DEFAULT_RADIUS_GATEWAY) {
|
||||
this.#input = input;
|
||||
this.#gateway = normalizeRadiusGatewayUrl(gateway);
|
||||
}
|
||||
|
||||
get gateway(): string {
|
||||
return this.#gateway;
|
||||
}
|
||||
|
||||
async resolve(options: {
|
||||
readonly required: boolean;
|
||||
readonly signal?: AbortSignal;
|
||||
}): Promise<RadiusRelayAuth | undefined> {
|
||||
options.signal?.throwIfAborted();
|
||||
if (process.env.PI_OFFLINE !== undefined) {
|
||||
if (options.required) throw new Error("Radius relay connections are unavailable in offline mode");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const explicit = await this.#explicitToken(options.signal);
|
||||
if (explicit !== undefined) return { gateway: this.#gateway, token: explicit };
|
||||
|
||||
this.#modelRuntime ??= ModelRuntime.create({ refreshOnCreate: false, allowModelNetwork: false });
|
||||
const runtime = await this.#modelRuntime;
|
||||
options.signal?.throwIfAborted();
|
||||
const token = getAuthCredential(
|
||||
await runtime.getAuth("radius", { minOAuthValidityMs: 5 * 60_000, signal: options.signal }),
|
||||
);
|
||||
if (token !== undefined && token.length > 0) return { gateway: this.#gateway, token };
|
||||
if (options.required) {
|
||||
throw new Error("Radius authentication is required; start Pi and run /login radius, then retry");
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async #explicitToken(signal: AbortSignal | undefined): Promise<string | undefined> {
|
||||
if (this.#input === undefined) return undefined;
|
||||
const value =
|
||||
this.#input.type === "token"
|
||||
? this.#input.token
|
||||
: await readFile(resolvePath(this.#input.path), { encoding: "utf8", signal });
|
||||
const token = value.trim();
|
||||
if (token.length === 0) throw new Error("Radius authentication token must not be empty");
|
||||
return token;
|
||||
}
|
||||
}
|
||||
724
third_party/pi/packages/coding-agent/src/experimental/radius-relay.ts
vendored
Normal file
724
third_party/pi/packages/coding-agent/src/experimental/radius-relay.ts
vendored
Normal file
@@ -0,0 +1,724 @@
|
||||
import type { ByteTransport, ByteTransportFactory, ByteTransportHandlers, Client } from "@earendil-works/pi-client";
|
||||
import { DEFAULT_MAX_FRAME_LENGTH, type ServerId } from "@earendil-works/pi-protocol";
|
||||
import type { Server } from "@earendil-works/pi-server";
|
||||
import { WebSocket } from "undici";
|
||||
import type { RadiusRelayAuthResolver } from "./radius-auth.ts";
|
||||
|
||||
export const RADIUS_RELAY_HOST_SUBPROTOCOL = "pi-session-relay.host.v1";
|
||||
export const RADIUS_RELAY_CLIENT_SUBPROTOCOL = "pi-session-relay.client.v1";
|
||||
|
||||
const RELAY_DATA_HEADER_BYTES = 18;
|
||||
const RELAY_DATA_FRAME_VERSION = 1;
|
||||
const RELAY_DATA_FRAME_TYPE = 1;
|
||||
const CONNECTION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
|
||||
const MAX_PENDING_BYTES = DEFAULT_MAX_FRAME_LENGTH * 4;
|
||||
const DRAIN_THRESHOLD_BYTES = 1024 * 1024;
|
||||
const HOST_RETRY_INITIAL_MS = 1_000;
|
||||
const HOST_RETRY_MAX_MS = 30_000;
|
||||
const MISSING_AUTH_RETRY_MS = 30_000;
|
||||
const CLIENT_RETRY_INITIAL_MS = 1_000;
|
||||
const CLIENT_RETRY_MAX_MS = 30_000;
|
||||
// Undici implements the browser WebSocket API, which permits callers to send
|
||||
// only code 1000 or application codes from 3000 through 4999. RFC protocol
|
||||
// codes such as 1002 and 1011 may be received but cannot be passed to close().
|
||||
const LOCAL_PROTOCOL_ERROR_CLOSE_CODE = 4000;
|
||||
const LOCAL_TRANSPORT_ERROR_CLOSE_CODE = 4001;
|
||||
|
||||
interface RadiusRelayMessageEvent extends Event {
|
||||
readonly data: unknown;
|
||||
}
|
||||
|
||||
interface RadiusRelayCloseEvent extends Event {
|
||||
readonly code: number;
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
interface RadiusRelayErrorEvent extends Event {
|
||||
readonly error?: unknown;
|
||||
readonly message?: string;
|
||||
}
|
||||
|
||||
export interface RadiusRelayWebSocket {
|
||||
binaryType: "arraybuffer" | "blob";
|
||||
readonly bufferedAmount: number;
|
||||
readonly protocol: string;
|
||||
readonly readyState: number;
|
||||
readonly OPEN: number;
|
||||
send(data: string | ArrayBuffer): void;
|
||||
close(code?: number, reason?: string): void;
|
||||
addEventListener(type: "open", listener: (event: Event) => void, options?: { once?: boolean }): void;
|
||||
addEventListener(
|
||||
type: "message",
|
||||
listener: (event: RadiusRelayMessageEvent) => void,
|
||||
options?: { once?: boolean },
|
||||
): void;
|
||||
addEventListener(
|
||||
type: "close",
|
||||
listener: (event: RadiusRelayCloseEvent) => void,
|
||||
options?: { once?: boolean },
|
||||
): void;
|
||||
addEventListener(
|
||||
type: "error",
|
||||
listener: (event: RadiusRelayErrorEvent) => void,
|
||||
options?: { once?: boolean },
|
||||
): void;
|
||||
removeEventListener(type: "open", listener: (event: Event) => void): void;
|
||||
removeEventListener(type: "message", listener: (event: RadiusRelayMessageEvent) => void): void;
|
||||
removeEventListener(type: "close", listener: (event: RadiusRelayCloseEvent) => void): void;
|
||||
removeEventListener(type: "error", listener: (event: RadiusRelayErrorEvent) => void): void;
|
||||
}
|
||||
|
||||
export type RadiusRelayWebSocketFactory = (options: {
|
||||
readonly url: string;
|
||||
readonly protocol: string;
|
||||
readonly authorization: string;
|
||||
}) => RadiusRelayWebSocket;
|
||||
|
||||
interface RelayByteConnection {
|
||||
readonly closed: boolean;
|
||||
send(chunk: Uint8Array): Promise<void>;
|
||||
close(finalChunk?: Uint8Array): Promise<void>;
|
||||
}
|
||||
|
||||
interface RelayByteConnectionHandler {
|
||||
onData(chunk: Uint8Array): void;
|
||||
onClose(): void;
|
||||
onError(error: Error): void;
|
||||
}
|
||||
|
||||
export type RadiusRelayHostStatus =
|
||||
| { readonly status: "not_authenticated" }
|
||||
| { readonly status: "connecting" }
|
||||
| { readonly status: "connected" }
|
||||
| { readonly status: "retrying"; readonly error: string };
|
||||
|
||||
export interface RadiusRelayHostOptions {
|
||||
readonly serverId: ServerId;
|
||||
readonly server: Pick<Server, "accept">;
|
||||
readonly auth: RadiusRelayAuthResolver;
|
||||
readonly webSocketFactory?: RadiusRelayWebSocketFactory;
|
||||
readonly onStatus?: (status: RadiusRelayHostStatus) => void;
|
||||
}
|
||||
|
||||
/** Maintain the experimental server's multiplexed, authenticated Radius host connection. */
|
||||
export class RadiusRelayHost {
|
||||
readonly #options: RadiusRelayHostOptions;
|
||||
readonly #abortController = new AbortController();
|
||||
readonly #connections = new Map<
|
||||
string,
|
||||
{ connection: RelayServerByteConnection; handler: RelayByteConnectionHandler }
|
||||
>();
|
||||
#socket: RadiusRelayWebSocket | undefined;
|
||||
#writer: OrderedWebSocketWriter | undefined;
|
||||
#loop: Promise<void> | undefined;
|
||||
#closed = false;
|
||||
|
||||
constructor(options: RadiusRelayHostOptions) {
|
||||
this.#options = options;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.#loop !== undefined) return;
|
||||
this.#loop = this.#run();
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.#closed) return;
|
||||
this.#closed = true;
|
||||
this.#abortController.abort();
|
||||
this.#writer?.close();
|
||||
this.#socket?.close(1000, "Pi server stopped");
|
||||
this.#dropConnections();
|
||||
await this.#loop;
|
||||
}
|
||||
|
||||
async #run(): Promise<void> {
|
||||
let retryMs = HOST_RETRY_INITIAL_MS;
|
||||
while (!this.#closed) {
|
||||
try {
|
||||
const auth = await this.#options.auth.resolve({
|
||||
required: false,
|
||||
signal: this.#abortController.signal,
|
||||
});
|
||||
if (auth === undefined) {
|
||||
this.#options.onStatus?.({ status: "not_authenticated" });
|
||||
await delay(MISSING_AUTH_RETRY_MS, this.#abortController.signal, true);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.#options.onStatus?.({ status: "connecting" });
|
||||
const socket = await openRadiusRelayWebSocket(
|
||||
relayWebSocketUrl(auth.gateway, this.#options.serverId),
|
||||
RADIUS_RELAY_HOST_SUBPROTOCOL,
|
||||
auth.token,
|
||||
this.#options.webSocketFactory ?? defaultWebSocketFactory,
|
||||
this.#abortController.signal,
|
||||
);
|
||||
retryMs = HOST_RETRY_INITIAL_MS;
|
||||
this.#options.onStatus?.({ status: "connected" });
|
||||
await this.#serve(socket);
|
||||
if (!this.#closed) throw new Error("Radius relay host disconnected");
|
||||
} catch (error) {
|
||||
if (this.#closed || this.#abortController.signal.aborted) break;
|
||||
this.#options.onStatus?.({ status: "retrying", error: errorMessage(error) });
|
||||
try {
|
||||
await delay(retryMs, this.#abortController.signal, true);
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
retryMs = Math.min(retryMs * 2, HOST_RETRY_MAX_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#serve(socket: RadiusRelayWebSocket): Promise<void> {
|
||||
this.#socket = socket;
|
||||
this.#writer = new OrderedWebSocketWriter(socket);
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
let terminal = false;
|
||||
const cleanup = (): void => {
|
||||
socket.removeEventListener("message", onMessage);
|
||||
socket.removeEventListener("close", onClose);
|
||||
socket.removeEventListener("error", onError);
|
||||
this.#writer?.close();
|
||||
if (this.#socket === socket) {
|
||||
this.#socket = undefined;
|
||||
this.#writer = undefined;
|
||||
}
|
||||
};
|
||||
const finish = (error?: Error): void => {
|
||||
if (terminal) return;
|
||||
terminal = true;
|
||||
cleanup();
|
||||
this.#dropConnections(error);
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
};
|
||||
const onMessage = (event: RadiusRelayMessageEvent): void => {
|
||||
try {
|
||||
this.#handleHostMessage(event.data);
|
||||
} catch (error) {
|
||||
closeWebSocket(socket, LOCAL_PROTOCOL_ERROR_CLOSE_CODE, "Radius relay protocol error");
|
||||
finish(toError(error));
|
||||
}
|
||||
};
|
||||
const onClose = (event: RadiusRelayCloseEvent): void => {
|
||||
finish(
|
||||
event.code === 1000
|
||||
? undefined
|
||||
: new Error(`Radius relay host closed (${event.code}${event.reason ? `: ${event.reason}` : ""})`),
|
||||
);
|
||||
};
|
||||
const onError = (event: RadiusRelayErrorEvent): void => finish(webSocketError(event));
|
||||
socket.addEventListener("message", onMessage);
|
||||
socket.addEventListener("close", onClose);
|
||||
socket.addEventListener("error", onError);
|
||||
});
|
||||
}
|
||||
|
||||
#handleHostMessage(value: unknown): void {
|
||||
if (typeof value === "string") {
|
||||
const control = parseHostControlMessage(value);
|
||||
switch (control.type) {
|
||||
case "ping":
|
||||
void this.#sendControl({ version: 1, type: "pong" }).catch(() => {
|
||||
if (this.#socket !== undefined) {
|
||||
closeWebSocket(this.#socket, LOCAL_TRANSPORT_ERROR_CLOSE_CODE, "Radius relay send failed");
|
||||
}
|
||||
});
|
||||
return;
|
||||
case "pong":
|
||||
return;
|
||||
case "connection_open":
|
||||
this.#openConnection(control.connection_id);
|
||||
return;
|
||||
case "connection_close":
|
||||
this.#remoteCloseConnection(control.connection_id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!(value instanceof ArrayBuffer)) throw new Error("Radius relay data message must be binary");
|
||||
const frame = parseRelayDataFrame(value);
|
||||
if (!frame) throw new Error("Invalid Radius relay data frame");
|
||||
const active = this.#connections.get(frame.connectionId);
|
||||
if (!active) {
|
||||
void this.#sendControl({
|
||||
version: 1,
|
||||
type: "connection_close",
|
||||
connection_id: frame.connectionId,
|
||||
code: 1000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
active.handler.onData(new Uint8Array(frame.payload));
|
||||
}
|
||||
|
||||
#openConnection(connectionId: string): void {
|
||||
if (this.#connections.has(connectionId)) throw new Error("Radius relay reused a connection ID");
|
||||
const connection = new RelayServerByteConnection(
|
||||
(chunk) => this.#sendData(connectionId, chunk),
|
||||
(finalChunk) => this.#serverCloseConnection(connectionId, finalChunk),
|
||||
);
|
||||
const handler = this.#options.server.accept(connection);
|
||||
if (connection.closed) {
|
||||
void this.#sendControl({ version: 1, type: "connection_close", connection_id: connectionId, code: 1012 });
|
||||
} else {
|
||||
this.#connections.set(connectionId, { connection, handler });
|
||||
}
|
||||
}
|
||||
|
||||
#remoteCloseConnection(connectionId: string): void {
|
||||
const active = this.#connections.get(connectionId);
|
||||
if (!active) return;
|
||||
this.#connections.delete(connectionId);
|
||||
active.connection.markClosed();
|
||||
active.handler.onClose();
|
||||
}
|
||||
|
||||
async #serverCloseConnection(connectionId: string, finalChunk?: Uint8Array): Promise<void> {
|
||||
if (!this.#connections.delete(connectionId)) return;
|
||||
if (finalChunk !== undefined) await this.#requireWriter().send(encodeRelayDataFrame(connectionId, finalChunk));
|
||||
await this.#sendControl({
|
||||
version: 1,
|
||||
type: "connection_close",
|
||||
connection_id: connectionId,
|
||||
code: 1000,
|
||||
});
|
||||
}
|
||||
|
||||
#sendData(connectionId: string, chunk: Uint8Array): Promise<void> {
|
||||
if (!this.#connections.has(connectionId)) return Promise.reject(new Error("Radius relay connection is closed"));
|
||||
return this.#requireWriter().send(encodeRelayDataFrame(connectionId, chunk));
|
||||
}
|
||||
|
||||
#sendControl(message: HostOutputControlMessage): Promise<void> {
|
||||
return this.#requireWriter().send(JSON.stringify(message));
|
||||
}
|
||||
|
||||
#requireWriter(): OrderedWebSocketWriter {
|
||||
if (!this.#writer) throw new Error("Radius relay host is disconnected");
|
||||
return this.#writer;
|
||||
}
|
||||
|
||||
#dropConnections(error?: Error): void {
|
||||
const connections = [...this.#connections.values()];
|
||||
this.#connections.clear();
|
||||
for (const active of connections) {
|
||||
active.connection.markClosed();
|
||||
if (error) active.handler.onError(error);
|
||||
else active.handler.onClose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createRadiusClientTransportFactory(options: {
|
||||
readonly serverId: ServerId;
|
||||
readonly auth: RadiusRelayAuthResolver;
|
||||
readonly webSocketFactory?: RadiusRelayWebSocketFactory;
|
||||
}): ByteTransportFactory {
|
||||
return async (handlers) => {
|
||||
const auth = await options.auth.resolve({ required: true });
|
||||
if (!auth) throw new Error("Radius authentication is required");
|
||||
const socket = await openRadiusRelayWebSocket(
|
||||
relayWebSocketUrl(auth.gateway, options.serverId),
|
||||
RADIUS_RELAY_CLIENT_SUBPROTOCOL,
|
||||
auth.token,
|
||||
options.webSocketFactory ?? defaultWebSocketFactory,
|
||||
);
|
||||
return new RadiusClientByteTransport(socket, handlers);
|
||||
};
|
||||
}
|
||||
|
||||
type RadiusReconnectClient = Pick<
|
||||
Client,
|
||||
| "attachment"
|
||||
| "connected"
|
||||
| "connectionState"
|
||||
| "disconnect"
|
||||
| "onAttachmentChange"
|
||||
| "onConnectionStateChange"
|
||||
| "reconnect"
|
||||
>;
|
||||
|
||||
/** Reconnect one established Radius client and restore its last selected Session. */
|
||||
export class RadiusClientReconnect {
|
||||
readonly #client: RadiusReconnectClient;
|
||||
readonly #reattach: (sessionId: string) => Promise<void>;
|
||||
readonly #abortController = new AbortController();
|
||||
readonly #removeConnectionListener: () => void;
|
||||
readonly #removeAttachmentListener: () => void;
|
||||
#desiredSessionId: string | undefined;
|
||||
#reconnecting: Promise<void> | undefined;
|
||||
#disposed = false;
|
||||
|
||||
constructor(client: RadiusReconnectClient, reattach: (sessionId: string) => Promise<void>) {
|
||||
this.#client = client;
|
||||
this.#reattach = reattach;
|
||||
this.#desiredSessionId = client.attachment?.sessionId;
|
||||
this.#removeAttachmentListener = client.onAttachmentChange((attachment) => {
|
||||
if (attachment !== undefined) this.#desiredSessionId = attachment.sessionId;
|
||||
else if (client.connected) this.#desiredSessionId = undefined;
|
||||
});
|
||||
this.#removeConnectionListener = client.onConnectionStateChange(({ state }) => {
|
||||
if (state === "disconnected" && !this.#disposed) this.#startReconnect();
|
||||
});
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
if (this.#disposed) return;
|
||||
this.#disposed = true;
|
||||
this.#removeConnectionListener();
|
||||
this.#removeAttachmentListener();
|
||||
this.#abortController.abort();
|
||||
if (this.#client.connectionState !== "disconnected") {
|
||||
this.#client.disconnect("Radius reconnect stopped");
|
||||
}
|
||||
await this.#reconnecting;
|
||||
}
|
||||
|
||||
#startReconnect(): void {
|
||||
if (this.#reconnecting !== undefined) return;
|
||||
this.#reconnecting = this.#runReconnect().finally(() => {
|
||||
this.#reconnecting = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
async #runReconnect(): Promise<void> {
|
||||
let retryMs = CLIENT_RETRY_INITIAL_MS;
|
||||
while (!this.#disposed && !this.#client.connected) {
|
||||
try {
|
||||
await delay(retryMs, this.#abortController.signal, false);
|
||||
await this.#client.reconnect();
|
||||
const sessionId = this.#desiredSessionId;
|
||||
if (sessionId !== undefined) await this.#reattach(sessionId);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (this.#disposed || this.#abortController.signal.aborted) return;
|
||||
if (this.#client.connected) this.#client.disconnect(errorMessage(error));
|
||||
retryMs = Math.min(retryMs * 2, CLIENT_RETRY_MAX_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RelayServerByteConnection implements RelayByteConnection {
|
||||
readonly #sendChunk: (chunk: Uint8Array) => Promise<void>;
|
||||
readonly #closeConnection: (finalChunk?: Uint8Array) => Promise<void>;
|
||||
#closed = false;
|
||||
|
||||
constructor(
|
||||
sendChunk: (chunk: Uint8Array) => Promise<void>,
|
||||
closeConnection: (finalChunk?: Uint8Array) => Promise<void>,
|
||||
) {
|
||||
this.#sendChunk = sendChunk;
|
||||
this.#closeConnection = closeConnection;
|
||||
}
|
||||
|
||||
get closed(): boolean {
|
||||
return this.#closed;
|
||||
}
|
||||
|
||||
send(chunk: Uint8Array): Promise<void> {
|
||||
if (this.#closed) return Promise.reject(new Error("Radius relay connection is closed"));
|
||||
return this.#sendChunk(chunk);
|
||||
}
|
||||
|
||||
async close(finalChunk?: Uint8Array): Promise<void> {
|
||||
if (this.#closed) return;
|
||||
this.#closed = true;
|
||||
await this.#closeConnection(finalChunk);
|
||||
}
|
||||
|
||||
markClosed(): void {
|
||||
this.#closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
class RadiusClientByteTransport implements ByteTransport {
|
||||
readonly #socket: RadiusRelayWebSocket;
|
||||
readonly #handlers: ByteTransportHandlers;
|
||||
readonly #writer: OrderedWebSocketWriter;
|
||||
readonly #onMessage: (event: RadiusRelayMessageEvent) => void;
|
||||
readonly #onClose: (event: RadiusRelayCloseEvent) => void;
|
||||
readonly #onError: (event: RadiusRelayErrorEvent) => void;
|
||||
#closed = false;
|
||||
|
||||
constructor(socket: RadiusRelayWebSocket, handlers: ByteTransportHandlers) {
|
||||
this.#socket = socket;
|
||||
this.#handlers = handlers;
|
||||
this.#writer = new OrderedWebSocketWriter(socket);
|
||||
this.#onMessage = (event) => {
|
||||
if (this.#closed) return;
|
||||
if (!(event.data instanceof ArrayBuffer)) {
|
||||
this.#fail(new Error("Radius relay client received a non-binary message"));
|
||||
return;
|
||||
}
|
||||
this.#handlers.onData(new Uint8Array(event.data));
|
||||
};
|
||||
this.#onClose = () => {
|
||||
if (this.#markClosed()) this.#handlers.onClose();
|
||||
};
|
||||
this.#onError = (event) => this.#fail(webSocketError(event));
|
||||
socket.addEventListener("message", this.#onMessage);
|
||||
socket.addEventListener("close", this.#onClose);
|
||||
socket.addEventListener("error", this.#onError);
|
||||
}
|
||||
|
||||
send(chunk: Uint8Array): Promise<void> {
|
||||
if (!(chunk instanceof Uint8Array))
|
||||
return Promise.reject(new TypeError("Radius relay chunks must be Uint8Array"));
|
||||
if (this.#closed) return Promise.reject(new Error("Radius relay client is closed"));
|
||||
return this.#writer.send(chunk.slice().buffer);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (!this.#markClosed()) return;
|
||||
this.#socket.close(1000, "Pi client closed");
|
||||
}
|
||||
|
||||
#fail(error: Error): void {
|
||||
if (!this.#markClosed()) return;
|
||||
closeWebSocket(this.#socket, LOCAL_TRANSPORT_ERROR_CLOSE_CODE, "Radius relay transport error");
|
||||
this.#handlers.onError(error);
|
||||
}
|
||||
|
||||
#markClosed(): boolean {
|
||||
if (this.#closed) return false;
|
||||
this.#closed = true;
|
||||
this.#writer.close();
|
||||
this.#socket.removeEventListener("message", this.#onMessage);
|
||||
this.#socket.removeEventListener("close", this.#onClose);
|
||||
this.#socket.removeEventListener("error", this.#onError);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class OrderedWebSocketWriter {
|
||||
readonly #socket: RadiusRelayWebSocket;
|
||||
#tail: Promise<void> = Promise.resolve();
|
||||
#pendingBytes = 0;
|
||||
#closed = false;
|
||||
|
||||
constructor(socket: RadiusRelayWebSocket) {
|
||||
this.#socket = socket;
|
||||
}
|
||||
|
||||
send(value: string | ArrayBuffer): Promise<void> {
|
||||
if (this.#closed) return Promise.reject(new Error("Radius relay WebSocket is closed"));
|
||||
const copied = typeof value === "string" ? value : value.slice(0);
|
||||
const byteLength = typeof copied === "string" ? Buffer.byteLength(copied) : copied.byteLength;
|
||||
if (this.#pendingBytes + byteLength > MAX_PENDING_BYTES) {
|
||||
return Promise.reject(new Error("Radius relay exceeded its pending byte limit"));
|
||||
}
|
||||
this.#pendingBytes += byteLength;
|
||||
const operation = this.#tail.then(async () => {
|
||||
if (this.#closed || this.#socket.readyState !== this.#socket.OPEN) {
|
||||
throw new Error("Radius relay WebSocket is closed");
|
||||
}
|
||||
this.#socket.send(copied);
|
||||
while (this.#socket.bufferedAmount > DRAIN_THRESHOLD_BYTES) {
|
||||
if (this.#closed || this.#socket.readyState !== this.#socket.OPEN) {
|
||||
throw new Error("Radius relay WebSocket closed during write");
|
||||
}
|
||||
await delay(5, undefined, false);
|
||||
}
|
||||
});
|
||||
const tracked = operation.finally(() => {
|
||||
this.#pendingBytes -= byteLength;
|
||||
});
|
||||
this.#tail = tracked.catch(() => {});
|
||||
return tracked;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.#closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
type HostInputControlMessage =
|
||||
| { readonly version: 1; readonly type: "ping" | "pong" }
|
||||
| { readonly version: 1; readonly type: "connection_open"; readonly connection_id: string }
|
||||
| {
|
||||
readonly version: 1;
|
||||
readonly type: "connection_close";
|
||||
readonly connection_id: string;
|
||||
readonly code?: number;
|
||||
};
|
||||
|
||||
type HostOutputControlMessage =
|
||||
| { readonly version: 1; readonly type: "pong" }
|
||||
| {
|
||||
readonly version: 1;
|
||||
readonly type: "connection_close";
|
||||
readonly connection_id: string;
|
||||
readonly code?: number;
|
||||
};
|
||||
|
||||
function parseHostControlMessage(value: string): HostInputControlMessage {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
throw new Error("Invalid Radius relay control message");
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("Invalid Radius relay control message");
|
||||
}
|
||||
const message = parsed as { version?: unknown; type?: unknown; connection_id?: unknown; code?: unknown };
|
||||
if (message.version !== 1) throw new Error("Unsupported Radius relay control version");
|
||||
if (message.type === "ping" || message.type === "pong") return { version: 1, type: message.type };
|
||||
if (
|
||||
(message.type === "connection_open" || message.type === "connection_close") &&
|
||||
typeof message.connection_id === "string" &&
|
||||
CONNECTION_ID_PATTERN.test(message.connection_id) &&
|
||||
(message.code === undefined ||
|
||||
(typeof message.code === "number" &&
|
||||
Number.isInteger(message.code) &&
|
||||
message.code >= 1000 &&
|
||||
message.code <= 4999))
|
||||
) {
|
||||
return message.type === "connection_open"
|
||||
? { version: 1, type: "connection_open", connection_id: message.connection_id }
|
||||
: {
|
||||
version: 1,
|
||||
type: "connection_close",
|
||||
connection_id: message.connection_id,
|
||||
...(message.code === undefined ? {} : { code: message.code }),
|
||||
};
|
||||
}
|
||||
throw new Error("Invalid Radius relay control message");
|
||||
}
|
||||
|
||||
export function encodeRelayDataFrame(connectionId: string, payload: Uint8Array): ArrayBuffer {
|
||||
if (!CONNECTION_ID_PATTERN.test(connectionId)) throw new TypeError("Invalid Radius relay connection ID");
|
||||
const frame = new Uint8Array(RELAY_DATA_HEADER_BYTES + payload.byteLength);
|
||||
frame[0] = RELAY_DATA_FRAME_VERSION;
|
||||
frame[1] = RELAY_DATA_FRAME_TYPE;
|
||||
const hex = connectionId.replaceAll("-", "");
|
||||
for (let index = 0; index < 16; index++) {
|
||||
frame[index + 2] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16);
|
||||
}
|
||||
frame.set(payload, RELAY_DATA_HEADER_BYTES);
|
||||
return frame.buffer;
|
||||
}
|
||||
|
||||
export function parseRelayDataFrame(
|
||||
frame: ArrayBuffer,
|
||||
): { readonly connectionId: string; readonly payload: ArrayBuffer } | undefined {
|
||||
if (frame.byteLength < RELAY_DATA_HEADER_BYTES) return undefined;
|
||||
const bytes = new Uint8Array(frame);
|
||||
if (bytes[0] !== RELAY_DATA_FRAME_VERSION || bytes[1] !== RELAY_DATA_FRAME_TYPE) return undefined;
|
||||
let hex = "";
|
||||
for (const byte of bytes.subarray(2, RELAY_DATA_HEADER_BYTES)) hex += byte.toString(16).padStart(2, "0");
|
||||
const connectionId = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
if (!CONNECTION_ID_PATTERN.test(connectionId)) return undefined;
|
||||
return { connectionId, payload: frame.slice(RELAY_DATA_HEADER_BYTES) };
|
||||
}
|
||||
|
||||
function relayWebSocketUrl(gateway: string, serverId: ServerId): string {
|
||||
const url = new URL(`/v1/session-relays/${serverId}/connect`, gateway);
|
||||
if (url.protocol === "https:") url.protocol = "wss:";
|
||||
else if (url.protocol === "http:") url.protocol = "ws:";
|
||||
else throw new Error(`Unsupported Radius gateway protocol: ${url.protocol}`);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function defaultWebSocketFactory(options: {
|
||||
readonly url: string;
|
||||
readonly protocol: string;
|
||||
readonly authorization: string;
|
||||
}): RadiusRelayWebSocket {
|
||||
return new WebSocket(options.url, {
|
||||
protocols: [options.protocol],
|
||||
headers: { authorization: options.authorization },
|
||||
}) as unknown as RadiusRelayWebSocket;
|
||||
}
|
||||
|
||||
function openRadiusRelayWebSocket(
|
||||
url: string,
|
||||
protocol: string,
|
||||
token: string,
|
||||
factory: RadiusRelayWebSocketFactory,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RadiusRelayWebSocket> {
|
||||
signal?.throwIfAborted();
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = factory({ url, protocol, authorization: `Bearer ${token}` });
|
||||
socket.binaryType = "arraybuffer";
|
||||
let settled = false;
|
||||
const cleanup = (): void => {
|
||||
socket.removeEventListener("open", onOpen);
|
||||
socket.removeEventListener("close", onClose);
|
||||
socket.removeEventListener("error", onError);
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
};
|
||||
const fail = (error: Error): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
closeWebSocket(socket, 1000, "Radius relay connection failed");
|
||||
reject(error);
|
||||
};
|
||||
const onOpen = (): void => {
|
||||
if (settled) return;
|
||||
if (socket.protocol !== protocol) {
|
||||
fail(new Error(`Radius relay selected unexpected WebSocket protocol ${JSON.stringify(socket.protocol)}`));
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(socket);
|
||||
};
|
||||
const onClose = (event: RadiusRelayCloseEvent): void =>
|
||||
fail(new Error(`Radius relay closed before connecting (${event.code})`));
|
||||
const onError = (event: RadiusRelayErrorEvent): void => fail(webSocketError(event));
|
||||
const onAbort = (): void => fail(new Error("Radius relay connection cancelled"));
|
||||
socket.addEventListener("open", onOpen, { once: true });
|
||||
socket.addEventListener("close", onClose, { once: true });
|
||||
socket.addEventListener("error", onError, { once: true });
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function closeWebSocket(socket: RadiusRelayWebSocket, code: number, reason: string): void {
|
||||
try {
|
||||
socket.close(code, reason);
|
||||
} catch {
|
||||
// A WebSocket can reject close() while its opening handshake is still pending.
|
||||
}
|
||||
}
|
||||
|
||||
function webSocketError(event: RadiusRelayErrorEvent): Error {
|
||||
if (event.error instanceof Error && event.error.message.trim().length > 0) return event.error;
|
||||
return new Error(event.message?.trim() || "Radius WebSocket connection failed");
|
||||
}
|
||||
|
||||
function delay(milliseconds: number, signal: AbortSignal | undefined, unref: boolean): Promise<void> {
|
||||
signal?.throwIfAborted();
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
resolve();
|
||||
}, milliseconds);
|
||||
if (unref) timer.unref();
|
||||
const onAbort = (): void => {
|
||||
clearTimeout(timer);
|
||||
cleanup();
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
const cleanup = (): void => signal?.removeEventListener("abort", onAbort);
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function toError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return toError(error).message;
|
||||
}
|
||||
798
third_party/pi/packages/coding-agent/src/experimental/server.ts
vendored
Normal file
798
third_party/pi/packages/coding-agent/src/experimental/server.ts
vendored
Normal file
@@ -0,0 +1,798 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { chmod, lstat, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { isAbsolute, join } from "node:path";
|
||||
import type { Context } from "@earendil-works/chord";
|
||||
import type { FacetBundleArtifact } from "@earendil-works/chord/node";
|
||||
import {
|
||||
BACKGROUND_CONTEXT,
|
||||
type JsonlSessionMetadata,
|
||||
JsonlSessionRepo,
|
||||
TODO_CONTEXT,
|
||||
} from "@earendil-works/pi-agent-core";
|
||||
import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
|
||||
import { Client, ServerError as ClientServerError, DisconnectedError } from "@earendil-works/pi-client";
|
||||
import { createUnixTransportFactory, type UnixServerRoute } from "@earendil-works/pi-client/unix";
|
||||
import { isServerId, type ServerId } from "@earendil-works/pi-protocol";
|
||||
import {
|
||||
ServerError as RoutedServerError,
|
||||
type Server,
|
||||
type ServerHost,
|
||||
SessionAmbiguousError,
|
||||
SessionNotFoundError,
|
||||
} from "@earendil-works/pi-server";
|
||||
import { createUnixServer, getUnixSocketPath } from "@earendil-works/pi-server/unix";
|
||||
import lockfile from "proper-lockfile";
|
||||
import type { AuthInput } from "../cli/experimental/command-options.ts";
|
||||
import { getAgentDir } from "../config.ts";
|
||||
import { resolvePath } from "../utils/paths.ts";
|
||||
import { CoordinatorConnection, type CoordinatorStartupLease, ensureCoordinator } from "./coordinator.ts";
|
||||
import { createPresentationFacetData } from "./plugins/bundled.ts";
|
||||
import {
|
||||
createServerPluginPackage,
|
||||
normalizePluginPackagePaths,
|
||||
readSessionPluginPackageProfile,
|
||||
removeSessionPluginPackageProfile,
|
||||
restoreServerPluginPackageProfile,
|
||||
writeSessionPluginPackageProfile,
|
||||
} from "./plugins/package.ts";
|
||||
import {
|
||||
consumeInternalProcessRole,
|
||||
isDirectInternalProcessEntry,
|
||||
spawnInternalProcess,
|
||||
terminateInternalProcess,
|
||||
} from "./process.ts";
|
||||
import { RadiusRelayAuthResolver } from "./radius-auth.ts";
|
||||
import { RadiusRelayHost, type RadiusRelayHostStatus } from "./radius-relay.ts";
|
||||
import { createExperimentalServerServices } from "./services/server.ts";
|
||||
import type { SessionCreateOptions, SessionSummary } from "./services/sessions.ts";
|
||||
import { SessionPluginSelectionConflictError, SessionWorkerManager } from "./session-worker-manager.ts";
|
||||
|
||||
export const ENV_SERVER_DIR = "PI_SERVER_DIR";
|
||||
export const ENV_SERVER_ID = "PI_SERVER_ID";
|
||||
|
||||
export function resolveServerDirectory(directory?: string): string {
|
||||
return resolvePath(directory ?? process.env[ENV_SERVER_DIR] ?? join(homedir(), ".pi", "server"));
|
||||
}
|
||||
|
||||
export async function ensurePrivateServerDirectory(directory: string): Promise<void> {
|
||||
if (typeof process.getuid !== "function") throw new Error("Unix socket directory requires a POSIX user ID");
|
||||
await mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
const stats = await lstat(directory);
|
||||
if (!stats.isDirectory()) throw new Error(`Unix socket directory is not a directory: ${directory}`);
|
||||
if (stats.uid !== process.getuid()) {
|
||||
throw new Error(`Unix socket directory is not owned by the current user: ${directory}`);
|
||||
}
|
||||
await chmod(directory, 0o700);
|
||||
}
|
||||
|
||||
export function resolveSessionDirectory(sessionDir?: string): string {
|
||||
return resolvePath(sessionDir ?? join(getAgentDir(), "experimental", "sessions"));
|
||||
}
|
||||
|
||||
const LOCK_STALE_MS = 30_000;
|
||||
const LOCK_RETRY_MS = 25;
|
||||
const LOCK_WAIT_MS = 30_000;
|
||||
const DEFAULT_SERVER_ID_FILE = "default-server-id";
|
||||
|
||||
export interface ServerProfile {
|
||||
readonly serverId: ServerId;
|
||||
release(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Lock one logical server ID in a shared experimental server directory. */
|
||||
export async function acquireServerProfile(directory: string, requestedServerId?: string): Promise<ServerProfile> {
|
||||
await mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
let serverId: ServerId;
|
||||
if (requestedServerId !== undefined) {
|
||||
if (!isServerId(requestedServerId)) throw new Error(`Invalid experimental server ID: ${requestedServerId}`);
|
||||
serverId = requestedServerId;
|
||||
} else {
|
||||
const path = join(directory, DEFAULT_SERVER_ID_FILE);
|
||||
try {
|
||||
const value = (await readFile(path, "utf8")).trim();
|
||||
if (!isServerId(value)) throw new Error(`Invalid default experimental server identity in ${path}`);
|
||||
serverId = value;
|
||||
} catch (error) {
|
||||
const code = error instanceof Error && "code" in error ? error.code : undefined;
|
||||
if (code !== "ENOENT") throw error;
|
||||
const candidate = randomUUID();
|
||||
try {
|
||||
await writeFile(path, candidate, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
||||
serverId = candidate;
|
||||
} catch (writeError) {
|
||||
const writeCode = writeError instanceof Error && "code" in writeError ? writeError.code : undefined;
|
||||
if (writeCode !== "EEXIST") throw writeError;
|
||||
const value = (await readFile(path, "utf8")).trim();
|
||||
if (!isServerId(value)) throw new Error(`Invalid default experimental server identity in ${path}`);
|
||||
serverId = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const release = await lockfile.lock(join(directory, `launcher-${serverId}`), {
|
||||
realpath: false,
|
||||
stale: LOCK_STALE_MS,
|
||||
update: LOCK_STALE_MS / 3,
|
||||
retries: {
|
||||
retries: Math.ceil(LOCK_WAIT_MS / LOCK_RETRY_MS),
|
||||
factor: 1,
|
||||
minTimeout: LOCK_RETRY_MS,
|
||||
maxTimeout: LOCK_RETRY_MS,
|
||||
maxRetryTime: LOCK_WAIT_MS,
|
||||
},
|
||||
});
|
||||
return { serverId, release };
|
||||
}
|
||||
|
||||
const ACTIVATION_TIMEOUT_MS = 10_000;
|
||||
const ACTIVATION_RETRY_MS = 10;
|
||||
|
||||
export interface ActivatedServer {
|
||||
readonly client: Client;
|
||||
readonly route: UnixServerRoute;
|
||||
}
|
||||
|
||||
export interface ActivateServerOptions {
|
||||
readonly directory: string;
|
||||
readonly requestedServerId?: ServerId | string;
|
||||
readonly sessionDir: string;
|
||||
readonly provider?: string;
|
||||
readonly model?: string;
|
||||
}
|
||||
|
||||
/** Ensure the selected logical server is reachable, launching the current Pi installation if needed. */
|
||||
export async function activateServer(options: ActivateServerOptions): Promise<ActivatedServer> {
|
||||
if (options.provider !== undefined && options.model === undefined) {
|
||||
throw new Error("Server model provider requires a model");
|
||||
}
|
||||
await ensurePrivateServerDirectory(options.directory);
|
||||
const profile = await acquireServerProfile(options.directory, options.requestedServerId);
|
||||
const serverId = profile.serverId;
|
||||
await profile.release();
|
||||
const route = { serverId, path: getUnixSocketPath(serverId, options.directory) };
|
||||
const release = await acquireServerActivation(options.directory, serverId);
|
||||
try {
|
||||
const existing = await connect(route);
|
||||
if (existing) {
|
||||
// Another activator won the race, so startup-only selections can no longer be applied.
|
||||
if (options.model !== undefined) {
|
||||
await existing.dispose();
|
||||
throw new Error("Model selection is only valid when automatically activating a new server");
|
||||
}
|
||||
return { client: existing, route };
|
||||
}
|
||||
const modelArgs =
|
||||
options.model === undefined
|
||||
? []
|
||||
: [
|
||||
JSON.stringify({
|
||||
...(options.provider === undefined ? {} : { provider: options.provider }),
|
||||
model: options.model,
|
||||
}),
|
||||
];
|
||||
const child = spawnInternalProcess("server", [options.directory, serverId, options.sessionDir, ...modelArgs]);
|
||||
let spawnError: Error | undefined;
|
||||
child.once("error", (error) => {
|
||||
spawnError = error;
|
||||
});
|
||||
try {
|
||||
const deadline = Date.now() + ACTIVATION_TIMEOUT_MS;
|
||||
while (true) {
|
||||
const client = await connect(route);
|
||||
if (client) return { client, route };
|
||||
if (spawnError) throw new Error("Failed to automatically activate server", { cause: spawnError });
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
throw new Error("Automatically activated server exited during startup");
|
||||
}
|
||||
if (Date.now() >= deadline) throw new Error("Timed out waiting for automatically activated server");
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, ACTIVATION_RETRY_MS));
|
||||
}
|
||||
} catch (error) {
|
||||
await terminateInternalProcess(child);
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
export function acquireServerActivation(directory: string, serverId: ServerId): Promise<() => Promise<void>> {
|
||||
return lockfile.lock(join(directory, `activation-${serverId}`), {
|
||||
realpath: false,
|
||||
stale: ACTIVATION_TIMEOUT_MS * 2,
|
||||
update: ACTIVATION_TIMEOUT_MS,
|
||||
retries: {
|
||||
retries: Math.ceil(ACTIVATION_TIMEOUT_MS / 25),
|
||||
factor: 1,
|
||||
minTimeout: 25,
|
||||
maxTimeout: 25,
|
||||
maxRetryTime: ACTIVATION_TIMEOUT_MS,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function connect(route: UnixServerRoute): Promise<Client | undefined> {
|
||||
const client = new Client({
|
||||
serverId: route.serverId,
|
||||
transportFactory: createUnixTransportFactory({ path: route.path }),
|
||||
});
|
||||
try {
|
||||
await client.connect();
|
||||
return client;
|
||||
} catch (error) {
|
||||
await client.dispose();
|
||||
if (error instanceof DisconnectedError || (error instanceof ClientServerError && error.code === "version")) {
|
||||
return undefined;
|
||||
}
|
||||
let current = error;
|
||||
const seen = new Set<unknown>();
|
||||
while (current instanceof Error && !seen.has(current)) {
|
||||
seen.add(current);
|
||||
if (
|
||||
"code" in current &&
|
||||
["ENOENT", "ECONNREFUSED", "ECONNRESET", "EPIPE", "ETIMEDOUT"].includes(String(current.code))
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
current = current.cause;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const AUTO_SERVER_STARTUP_GRACE_MS = 10_000;
|
||||
const AUTO_SERVER_IDLE_GRACE_MS = 1_000;
|
||||
|
||||
/** Reconcile operator, startup, client, and worker holds for one server generation. */
|
||||
export class ServerLifetime {
|
||||
readonly #keepAlive: boolean;
|
||||
#connectionCount = 0;
|
||||
#workerCount = 0;
|
||||
#startupHeld: boolean;
|
||||
#startupTimer: NodeJS.Timeout | undefined;
|
||||
#retirementTimer: NodeJS.Timeout | undefined;
|
||||
#retire: (() => void) | undefined;
|
||||
#stopped = false;
|
||||
|
||||
constructor(keepAlive: boolean) {
|
||||
this.#keepAlive = keepAlive;
|
||||
this.#startupHeld = !keepAlive;
|
||||
}
|
||||
|
||||
start(retire: () => void): void {
|
||||
this.#retire = retire;
|
||||
if (this.#startupHeld) {
|
||||
this.#startupTimer = setTimeout(() => {
|
||||
this.#startupTimer = undefined;
|
||||
this.#startupHeld = false;
|
||||
this.#reconcile();
|
||||
}, AUTO_SERVER_STARTUP_GRACE_MS);
|
||||
this.#startupTimer.unref();
|
||||
}
|
||||
this.#reconcile();
|
||||
}
|
||||
|
||||
setConnectionCount(count: number): void {
|
||||
this.#connectionCount = count;
|
||||
if (count > 0 && this.#startupHeld) {
|
||||
this.#startupHeld = false;
|
||||
if (this.#startupTimer) clearTimeout(this.#startupTimer);
|
||||
this.#startupTimer = undefined;
|
||||
}
|
||||
this.#reconcile();
|
||||
}
|
||||
|
||||
setWorkerCount(count: number): void {
|
||||
this.#workerCount = count;
|
||||
this.#reconcile();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.#stopped = true;
|
||||
if (this.#startupTimer) clearTimeout(this.#startupTimer);
|
||||
if (this.#retirementTimer) clearTimeout(this.#retirementTimer);
|
||||
this.#startupTimer = undefined;
|
||||
this.#retirementTimer = undefined;
|
||||
}
|
||||
|
||||
#reconcile(): void {
|
||||
if (
|
||||
this.#stopped ||
|
||||
this.#keepAlive ||
|
||||
this.#startupHeld ||
|
||||
this.#connectionCount !== 0 ||
|
||||
this.#workerCount !== 0
|
||||
) {
|
||||
if (this.#retirementTimer) clearTimeout(this.#retirementTimer);
|
||||
this.#retirementTimer = undefined;
|
||||
return;
|
||||
}
|
||||
const retire = this.#retire;
|
||||
if (this.#retirementTimer || !retire) return;
|
||||
this.#retirementTimer = setTimeout(() => {
|
||||
this.#retirementTimer = undefined;
|
||||
if (!this.#stopped && !this.#startupHeld && this.#connectionCount === 0 && this.#workerCount === 0) {
|
||||
retire();
|
||||
}
|
||||
}, AUTO_SERVER_IDLE_GRACE_MS);
|
||||
this.#retirementTimer.unref();
|
||||
}
|
||||
}
|
||||
|
||||
export interface RunningServer {
|
||||
readonly serverId: string;
|
||||
readonly sessionDir: string;
|
||||
readonly socketPath: string;
|
||||
readonly server: Server;
|
||||
readonly workerPids: ReadonlyMap<string, number>;
|
||||
readonly closed: Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface StartServerOptions {
|
||||
/** Server profile and socket directory. Defaults to PI_SERVER_DIR or ~/.pi/server. */
|
||||
readonly directory?: string;
|
||||
/** Logical service ID. Defaults to PI_SERVER_ID or the directory's default-server-id. */
|
||||
readonly serverId?: ServerId;
|
||||
/** Durable session directory. Defaults to the experimental directory under the configured agent directory. */
|
||||
readonly sessionDir?: string;
|
||||
/** Optional provider for an explicitly selected Session worker model. */
|
||||
readonly provider?: string;
|
||||
/** Optional model override for newly started Session workers. */
|
||||
readonly model?: string;
|
||||
/** Hold the server open without client or Session demand. Defaults to true for foreground servers. */
|
||||
readonly keepAlive?: boolean;
|
||||
/** Optional explicit Radius credential. Stored Radius auth is used when omitted. */
|
||||
readonly relayAuth?: AuthInput;
|
||||
/** Explicit plugin packages. Undefined restores the logical server profile; an empty list clears it. */
|
||||
readonly pluginPackages?: readonly string[];
|
||||
readonly onRelayStatus?: (status: RadiusRelayHostStatus) => void;
|
||||
}
|
||||
|
||||
interface ResolvedSessionPlugins {
|
||||
readonly packagePaths: readonly string[];
|
||||
readonly manifestPaths: readonly string[];
|
||||
readonly presentationArtifacts: readonly FacetBundleArtifact[];
|
||||
}
|
||||
|
||||
interface StartServerBackendOptions {
|
||||
readonly path: string;
|
||||
readonly serverId: ServerId;
|
||||
readonly sessionDir?: string;
|
||||
resolveSessionPlugins(
|
||||
metadata: JsonlSessionMetadata,
|
||||
packagePaths: readonly string[] | undefined,
|
||||
context: Context,
|
||||
): Promise<ResolvedSessionPlugins>;
|
||||
removeSessionPlugins(metadata: JsonlSessionMetadata): Promise<void>;
|
||||
reloadPresentationFacetBundles(packagePaths: readonly string[]): Promise<readonly FacetBundleArtifact[]>;
|
||||
}
|
||||
|
||||
interface RunningServerBackend extends RunningServer {
|
||||
refreshSessions(): Promise<void>;
|
||||
}
|
||||
|
||||
async function startServerBackend(
|
||||
options: StartServerBackendOptions,
|
||||
workers: SessionWorkerManager,
|
||||
onConnectionCountChanged?: (count: number) => void,
|
||||
): Promise<RunningServerBackend> {
|
||||
const serverId = options.serverId;
|
||||
const sessionDir = resolveSessionDirectory(options.sessionDir);
|
||||
const executionEnv = new NodeExecutionEnv({ cwd: process.cwd() });
|
||||
const repo = new JsonlSessionRepo({ fileSystem: executionEnv, sessionsRoot: sessionDir });
|
||||
const listSessions = async (context: Context): Promise<JsonlSessionMetadata[]> => {
|
||||
const sessions = new Map((await repo.list(undefined, context)).map((metadata) => [metadata.path, metadata]));
|
||||
for (const metadata of workers.trackedSessions) sessions.set(metadata.path, metadata);
|
||||
return [...sessions.values()];
|
||||
};
|
||||
const resolveSession = async (sessionId: string, context: Context): Promise<JsonlSessionMetadata> => {
|
||||
const matches = (await listSessions(context)).filter((metadata) => metadata.id === sessionId);
|
||||
if (matches.length === 0) throw new SessionNotFoundError(`Unknown session: ${sessionId}`);
|
||||
if (matches.length > 1) throw new SessionAmbiguousError();
|
||||
return matches[0]!;
|
||||
};
|
||||
const createSession = async (
|
||||
createOptions: SessionCreateOptions,
|
||||
context: Context,
|
||||
): Promise<JsonlSessionMetadata> => {
|
||||
const session = await repo.create({ ...createOptions, cwd: process.cwd() }, context);
|
||||
try {
|
||||
return session.metadata;
|
||||
} finally {
|
||||
await session.close(context);
|
||||
}
|
||||
};
|
||||
const summarize = (metadata: JsonlSessionMetadata): SessionSummary => ({
|
||||
serverId,
|
||||
sessionId: metadata.id,
|
||||
createdAt: metadata.createdAt,
|
||||
});
|
||||
const closeStorage = async (): Promise<void> => {
|
||||
const cleanup = await Promise.allSettled([repo.close(TODO_CONTEXT), executionEnv.cleanup(TODO_CONTEXT)]);
|
||||
const errors = cleanup.flatMap((result) => (result.status === "rejected" ? [result.reason] : []));
|
||||
if (errors.length === 1) throw errors[0];
|
||||
if (errors.length > 1) throw new AggregateError(errors, "Experimental session storage cleanup failed");
|
||||
};
|
||||
const serverServices = await createExperimentalServerServices({
|
||||
list: async (context) =>
|
||||
(await listSessions(context))
|
||||
.map(summarize)
|
||||
.sort((left, right) => left.sessionId.localeCompare(right.sessionId) || left.createdAt - right.createdAt),
|
||||
create: async (createOptions, context) => summarize(await createSession(createOptions, context)),
|
||||
remove: async (sessionId, context) => {
|
||||
const metadata = await resolveSession(sessionId, context);
|
||||
await workers.closeSession(metadata, context);
|
||||
await repo.delete(metadata, context);
|
||||
await options.removeSessionPlugins(metadata);
|
||||
},
|
||||
async prepareSessionPlugins(sessionId, packagePaths, context) {
|
||||
const metadata = await resolveSession(sessionId, context);
|
||||
let selected: ResolvedSessionPlugins;
|
||||
try {
|
||||
selected = await options.resolveSessionPlugins(metadata, packagePaths, context);
|
||||
workers.assertSessionPluginManifestPaths(metadata, selected.manifestPaths);
|
||||
} catch (error) {
|
||||
if (error instanceof SessionPluginSelectionConflictError) {
|
||||
throw new RoutedServerError("service_invalid_value", error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
packagePaths: selected.packagePaths,
|
||||
presentationPlugins: createPresentationFacetData(selected.presentationArtifacts),
|
||||
};
|
||||
},
|
||||
async reloadPresentationPlugins(packagePaths) {
|
||||
return createPresentationFacetData(await options.reloadPresentationFacetBundles(packagePaths));
|
||||
},
|
||||
}).catch(async (error: unknown) => {
|
||||
try {
|
||||
await closeStorage();
|
||||
} catch (cleanupError) {
|
||||
throw new AggregateError([error, cleanupError], "Server service startup and cleanup failed");
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
const host: ServerHost<JsonlSessionMetadata> = {
|
||||
serverServices: serverServices.host,
|
||||
resolveSession,
|
||||
openSession: async (metadata, context) => {
|
||||
const selected = await options.resolveSessionPlugins(metadata, undefined, context);
|
||||
return workers.openSession(metadata, context, selected.manifestPaths);
|
||||
},
|
||||
};
|
||||
const socketPath = options.path;
|
||||
const closeCatalog = async (): Promise<void> => {
|
||||
const cleanup = await Promise.allSettled([serverServices.dispose(), closeStorage()]);
|
||||
const errors = cleanup.flatMap((result) => (result.status === "rejected" ? [result.reason] : []));
|
||||
if (errors.length === 1) throw errors[0];
|
||||
if (errors.length > 1) throw new AggregateError(errors, "Experimental session catalog cleanup failed");
|
||||
};
|
||||
const server = createUnixServer(host, {
|
||||
serverId,
|
||||
path: socketPath,
|
||||
mode: 0o600,
|
||||
onConnectionCountChanged,
|
||||
});
|
||||
try {
|
||||
await server.start();
|
||||
} catch (error) {
|
||||
const cleanup = await Promise.allSettled([server.close(), closeCatalog()]);
|
||||
const cleanupErrors = cleanup.flatMap((result) => (result.status === "rejected" ? [result.reason] : []));
|
||||
if (cleanupErrors.length > 0) {
|
||||
throw new AggregateError([error, ...cleanupErrors], "Experimental server startup and cleanup failed");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
let closePromise: Promise<void> | undefined;
|
||||
const closed = server.closed.then(
|
||||
() => closeCatalog(),
|
||||
async (serverError: unknown) => {
|
||||
try {
|
||||
await closeCatalog();
|
||||
} catch (repoError) {
|
||||
throw new AggregateError([serverError, repoError], "Server and repository shutdown failed");
|
||||
}
|
||||
throw serverError;
|
||||
},
|
||||
);
|
||||
return {
|
||||
serverId,
|
||||
sessionDir,
|
||||
socketPath,
|
||||
server,
|
||||
workerPids: workers.workerPids,
|
||||
closed,
|
||||
refreshSessions: () => serverServices.refresh(BACKGROUND_CONTEXT),
|
||||
close() {
|
||||
closePromise ??= server.close().then(
|
||||
() => closed,
|
||||
() => closed,
|
||||
);
|
||||
return closePromise;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Start a replaceable experimental server behind the stable coordinator endpoint. */
|
||||
export async function startServer(options: StartServerOptions = {}): Promise<RunningServer> {
|
||||
if (options.provider !== undefined && options.model === undefined) {
|
||||
throw new Error("Server model provider requires a model");
|
||||
}
|
||||
const workerModel =
|
||||
options.model === undefined
|
||||
? undefined
|
||||
: { ...(options.provider === undefined ? {} : { provider: options.provider }), model: options.model };
|
||||
const directory = resolveServerDirectory(options.directory);
|
||||
const { serverId, release } = await acquireServerProfile(directory, options.serverId ?? process.env[ENV_SERVER_ID]);
|
||||
const lifetime = new ServerLifetime(options.keepAlive ?? true);
|
||||
let backend: RunningServerBackend | undefined;
|
||||
let coordinator: CoordinatorConnection | undefined;
|
||||
let startupLease: CoordinatorStartupLease | undefined;
|
||||
let workers: SessionWorkerManager | undefined;
|
||||
let relay: RadiusRelayHost | undefined;
|
||||
let released = false;
|
||||
try {
|
||||
await ensurePrivateServerDirectory(directory);
|
||||
const pluginPackagePaths = await restoreServerPluginPackageProfile(directory, serverId, options.pluginPackages);
|
||||
const pluginPackages = new Map<string, ReturnType<typeof createServerPluginPackage>>();
|
||||
const getPluginPackage = (packagePath: string): ReturnType<typeof createServerPluginPackage> => {
|
||||
let plugin = pluginPackages.get(packagePath);
|
||||
if (plugin === undefined) {
|
||||
plugin = createServerPluginPackage(directory, serverId, packagePath);
|
||||
pluginPackages.set(packagePath, plugin);
|
||||
}
|
||||
return plugin;
|
||||
};
|
||||
const buildPluginSelection = async (packagePaths: readonly string[]): Promise<ResolvedSessionPlugins> => {
|
||||
const normalizedPackagePaths = normalizePluginPackagePaths(packagePaths);
|
||||
const plugins = normalizedPackagePaths.map(getPluginPackage);
|
||||
const built = (await Promise.all(plugins.map((plugin) => plugin.build()))).flat();
|
||||
return {
|
||||
packagePaths: normalizedPackagePaths,
|
||||
manifestPaths: plugins.map((plugin) => plugin.manifestPath),
|
||||
presentationArtifacts: built,
|
||||
};
|
||||
};
|
||||
let defaultPluginSelection = await buildPluginSelection(pluginPackagePaths);
|
||||
const sessionPluginSelections = new Map<string, ResolvedSessionPlugins>();
|
||||
const resolveSessionPlugins = async (
|
||||
metadata: JsonlSessionMetadata,
|
||||
requestedPackagePaths: readonly string[] | undefined,
|
||||
_context: Context,
|
||||
): Promise<ResolvedSessionPlugins> => {
|
||||
if (requestedPackagePaths !== undefined) {
|
||||
const normalizedPackagePaths = normalizePluginPackagePaths(requestedPackagePaths);
|
||||
const current = sessionPluginSelections.get(metadata.path);
|
||||
if (current !== undefined && sameStrings(current.packagePaths, normalizedPackagePaths)) return current;
|
||||
const requestedManifestPaths = normalizedPackagePaths.map(
|
||||
(packagePath) => getPluginPackage(packagePath).manifestPath,
|
||||
);
|
||||
workers?.assertSessionPluginManifestPaths(metadata, requestedManifestPaths);
|
||||
const candidate = await buildPluginSelection(normalizedPackagePaths);
|
||||
workers?.assertSessionPluginManifestPaths(metadata, candidate.manifestPaths);
|
||||
await writeSessionPluginPackageProfile(directory, serverId, metadata.path, candidate.packagePaths);
|
||||
sessionPluginSelections.set(metadata.path, candidate);
|
||||
return candidate;
|
||||
}
|
||||
const cached = sessionPluginSelections.get(metadata.path);
|
||||
if (cached !== undefined) return cached;
|
||||
const storedPackagePaths = await readSessionPluginPackageProfile(directory, serverId, metadata.path);
|
||||
const selected =
|
||||
storedPackagePaths === undefined ? defaultPluginSelection : await buildPluginSelection(storedPackagePaths);
|
||||
if (storedPackagePaths === undefined) {
|
||||
await writeSessionPluginPackageProfile(directory, serverId, metadata.path, selected.packagePaths);
|
||||
}
|
||||
sessionPluginSelections.set(metadata.path, selected);
|
||||
return selected;
|
||||
};
|
||||
const removeSessionPlugins = async (metadata: JsonlSessionMetadata): Promise<void> => {
|
||||
sessionPluginSelections.delete(metadata.path);
|
||||
await removeSessionPluginPackageProfile(directory, serverId, metadata.path);
|
||||
};
|
||||
const reloadPresentationFacetBundles = async (
|
||||
packagePaths: readonly string[],
|
||||
): Promise<readonly FacetBundleArtifact[]> => {
|
||||
const reloaded = await buildPluginSelection(packagePaths);
|
||||
if (sameStrings(defaultPluginSelection.packagePaths, reloaded.packagePaths)) {
|
||||
defaultPluginSelection = reloaded;
|
||||
}
|
||||
for (const [sessionPath, selected] of sessionPluginSelections) {
|
||||
if (sameStrings(selected.packagePaths, reloaded.packagePaths)) {
|
||||
sessionPluginSelections.set(sessionPath, reloaded);
|
||||
}
|
||||
}
|
||||
return reloaded.presentationArtifacts;
|
||||
};
|
||||
const socketPath = getUnixSocketPath(serverId, directory);
|
||||
const controlPath = join(directory, `control-${serverId}.sock`);
|
||||
const serverNonce = randomUUID().replaceAll("-", "").slice(0, 12);
|
||||
const serverPath = join(directory, `server-${serverId}-${serverNonce}.sock`);
|
||||
startupLease = await ensureCoordinator(socketPath, controlPath);
|
||||
coordinator = new CoordinatorConnection({ controlPath, endpoint: serverPath });
|
||||
const sessionDir = resolveSessionDirectory(options.sessionDir);
|
||||
workers = new SessionWorkerManager(coordinator, sessionDir, workerModel, (count) =>
|
||||
lifetime.setWorkerCount(count),
|
||||
);
|
||||
backend = await startServerBackend(
|
||||
{
|
||||
path: serverPath,
|
||||
serverId,
|
||||
sessionDir: options.sessionDir,
|
||||
resolveSessionPlugins,
|
||||
removeSessionPlugins,
|
||||
reloadPresentationFacetBundles,
|
||||
},
|
||||
workers,
|
||||
(count) => lifetime.setConnectionCount(count),
|
||||
);
|
||||
await coordinator.connect();
|
||||
startupLease.close();
|
||||
startupLease = undefined;
|
||||
await workers.discover(coordinator.peerIds);
|
||||
await backend.refreshSessions();
|
||||
relay = new RadiusRelayHost({
|
||||
serverId,
|
||||
server: backend.server,
|
||||
auth: new RadiusRelayAuthResolver(options.relayAuth),
|
||||
onStatus: options.onRelayStatus,
|
||||
});
|
||||
relay.start();
|
||||
|
||||
const activeBackend = backend;
|
||||
const activeCoordinator = coordinator;
|
||||
const activeWorkers = workers;
|
||||
const activeRelay = relay;
|
||||
void activeCoordinator.replaced
|
||||
.then(async () => {
|
||||
lifetime.stop();
|
||||
activeWorkers.detach();
|
||||
await activeRelay.close();
|
||||
await activeBackend.close();
|
||||
})
|
||||
.finally(() => activeCoordinator.close())
|
||||
.catch(() => {});
|
||||
let closePromise: Promise<void> | undefined;
|
||||
const runtime: RunningServer = {
|
||||
serverId,
|
||||
sessionDir: activeBackend.sessionDir,
|
||||
socketPath,
|
||||
server: activeBackend.server,
|
||||
workerPids: activeWorkers.workerPids,
|
||||
closed: activeBackend.closed.finally(() => activeRelay.close()),
|
||||
close() {
|
||||
lifetime.stop();
|
||||
closePromise ??= (async () => {
|
||||
try {
|
||||
await activeRelay.close();
|
||||
await activeBackend.close();
|
||||
} finally {
|
||||
try {
|
||||
if (activeCoordinator.wasReplaced) activeWorkers.detach();
|
||||
else await activeWorkers.shutdown();
|
||||
} finally {
|
||||
activeCoordinator.close();
|
||||
}
|
||||
}
|
||||
})();
|
||||
return closePromise;
|
||||
},
|
||||
};
|
||||
lifetime.start(() => {
|
||||
void runtime.close().catch(() => {});
|
||||
});
|
||||
released = true;
|
||||
await release();
|
||||
return runtime;
|
||||
} catch (error) {
|
||||
lifetime.stop();
|
||||
startupLease?.close();
|
||||
if (coordinator?.wasReplaced) workers?.detach();
|
||||
const cleanup = await Promise.allSettled([
|
||||
relay?.close(),
|
||||
backend?.close(),
|
||||
coordinator?.wasReplaced ? undefined : workers?.shutdown(),
|
||||
Promise.resolve(coordinator?.close()),
|
||||
released ? undefined : release(),
|
||||
]);
|
||||
const cleanupErrors = cleanup.flatMap((result) => (result.status === "rejected" ? [result.reason] : []));
|
||||
if (cleanupErrors.length > 0) {
|
||||
throw new AggregateError([error, ...cleanupErrors], "Server runtime startup and cleanup failed");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Start an operator-held server while serializing against automatic cold activation. */
|
||||
export async function startForegroundServer(
|
||||
options: Omit<StartServerOptions, "keepAlive"> = {},
|
||||
): Promise<RunningServer> {
|
||||
const directory = resolveServerDirectory(options.directory);
|
||||
await ensurePrivateServerDirectory(directory);
|
||||
const profile = await acquireServerProfile(directory, options.serverId ?? process.env[ENV_SERVER_ID]);
|
||||
const serverId = profile.serverId;
|
||||
await profile.release();
|
||||
const release = await acquireServerActivation(directory, serverId);
|
||||
try {
|
||||
return await startServer({
|
||||
...options,
|
||||
directory,
|
||||
serverId,
|
||||
keepAlive: true,
|
||||
});
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
function parseServerModelOptions(value: string | undefined): { provider?: string; model: string } | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch (error) {
|
||||
throw new Error("Internal server received invalid model options", { cause: error });
|
||||
}
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
throw new Error("Internal server received invalid model options");
|
||||
}
|
||||
const keys = Object.keys(parsed);
|
||||
const model = "model" in parsed ? parsed.model : undefined;
|
||||
const provider = "provider" in parsed ? parsed.provider : undefined;
|
||||
if (
|
||||
keys.some((key) => key !== "provider" && key !== "model") ||
|
||||
typeof model !== "string" ||
|
||||
model.length === 0 ||
|
||||
(provider !== undefined && (typeof provider !== "string" || provider.length === 0))
|
||||
) {
|
||||
throw new Error("Internal server received invalid model options");
|
||||
}
|
||||
return provider === undefined ? { model } : { provider, model };
|
||||
}
|
||||
|
||||
/** Run an automatically activated server until its client and Session demand disappears. */
|
||||
export async function runServerProcess(args: readonly string[]): Promise<void> {
|
||||
const [directory, serverId, sessionDir, serializedModel] = args;
|
||||
if (args.length > 4) throw new Error("Internal server received unexpected arguments");
|
||||
if (!directory || !isAbsolute(directory)) throw new Error("Internal server requires an absolute server directory");
|
||||
if (!isServerId(serverId)) throw new Error("Internal server requires a canonical server ID");
|
||||
if (!sessionDir || !isAbsolute(sessionDir))
|
||||
throw new Error("Internal server requires an absolute Session directory");
|
||||
const workerModel = parseServerModelOptions(serializedModel);
|
||||
|
||||
const runtime = await startServer({
|
||||
directory,
|
||||
serverId,
|
||||
sessionDir,
|
||||
keepAlive: false,
|
||||
...workerModel,
|
||||
});
|
||||
const close = (): void => {
|
||||
void runtime.close().catch(() => {});
|
||||
};
|
||||
process.once("SIGINT", close);
|
||||
process.once("SIGTERM", close);
|
||||
try {
|
||||
await runtime.closed;
|
||||
} finally {
|
||||
process.off("SIGINT", close);
|
||||
process.off("SIGTERM", close);
|
||||
await runtime.close();
|
||||
}
|
||||
}
|
||||
|
||||
function sameStrings(left: readonly string[], right: readonly string[]): boolean {
|
||||
return left.length === right.length && left.every((value, index) => value === right[index]);
|
||||
}
|
||||
|
||||
if (isDirectInternalProcessEntry(import.meta.url)) {
|
||||
const role = consumeInternalProcessRole();
|
||||
if (role !== "server") throw new Error("Server entrypoint requires an internal server invocation");
|
||||
void runServerProcess(process.argv.slice(2)).catch((error: unknown) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
25
third_party/pi/packages/coding-agent/src/experimental/services/README.md
vendored
Normal file
25
third_party/pi/packages/coding-agent/src/experimental/services/README.md
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
# Experimental client/server service slices
|
||||
|
||||
Facet setup generates each host's RPC service catalogue from its provided non-local tokens. Remote service sources obtain those catalogues and bind only services required by consuming facets; there is no handwritten built-in service inventory. With no selected Session, its deferred source admits unresolved requirements as unavailable and keeps their handles disconnected. Attachment validates them against the worker's generated catalogue, which is cached for later detached generations. Keyed services hydrate as an empty directory until their owning feature spawns an instance.
|
||||
|
||||
| Scope | Service | Current slice | Continuation point |
|
||||
|---|---|---|---|
|
||||
| server | `SessionDirectory` | replicated state implemented | add authenticated per-client projection when identity lands |
|
||||
| server | `SessionManagement` | create, remove, attach, detach implemented | add authenticated workspace authorization |
|
||||
| server | `PresentationPlugins` | prepares the selected Session branch's matching TUI artifacts and reloads that branch | add authenticated plugin policy |
|
||||
| session | `SessionPlugins` | reloads the configured Session facet generation | add coordinated multi-worker reload reporting |
|
||||
| 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 | `Transcript` | replicated lane state with source-event metadata | add projections only when another presentation needs them |
|
||||
| presentation | `SlashCommands` | process-local contribution registry with model, thinking, compact, reload, and example hello commands | add more presentation hookpoints only as concrete plugin slices require them |
|
||||
| presentation | `PresentationUI` | process-local selection and status capabilities | add narrowly scoped UI capabilities only when commands require them |
|
||||
|
||||
`ServerServiceSource.connection` and `SessionServiceSource.attachment` are implemented local control states. Session directory, creation, and address DTOs are owned by these coding-agent service contracts rather than `pi-protocol`; the transport treats their payloads as opaque service data.
|
||||
|
||||
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 always uses the stable coding agent's alternate-screen renderer and shared transcript/dock viewport. It loads configured theme resources and uses the stable terminal light/dark detection and appearance-change notifications. Its replicated state feeds the stable editor, message, tool, status, theme, and tool-renderer components. The presentation drives the worker-owned main lane through `AgentController` and renders the `Transcript` service's complete replicated value while controller calls are pending. The provider remains the sole Harness reducer and mutates Transcript's tracked snapshot before publishing each event. Chord flushes compact operations once per publication, while each client/state pairing encodes them with an independent path dictionary. Chord reconstructs presentation replicas and owns hydration, sequencing, and gap detection.
|
||||
|
||||
A foreground server uses repeatable `-e` options to establish its default Session and TUI facets. A local client may instead select packages for the Session it creates or resumes; that branch selection is persisted with the Session and does not alter other workers or the server default. Before attaching, the server asks Chord to build conventional `src/session.ts` and `src/tui.ts` entries into separate `plugin-builds/` directories, passes the generated manifest paths to that Session worker, and returns the matching TUI artifacts. The Session worker loads built-in facets and separately owned plugin generations, then creates one active `FacetHost`. `/reload` atomically rebuilds the packages, loads fresh plugin candidates, cuts them over through `FacetHost.reload()`, and disposes the retired generations. Host-created implementation dependencies such as `AgentLane`, `ModelRuntime`, and `SettingsManager` are passed directly to built-in facet factories; they are not exposed as services. The TUI loads configured presentation facets and adds one private bridge facet that consumes the connected server and selected-Session services for `ExperimentalClientTui`. Its local presentation services are `SlashCommands`, which owns command contributions, and `PresentationUI`, which exposes selection and status rendering without exposing the raw TUI. Command callbacks receive Chord `Context` directly. Facets consume `AgentController` explicitly for prompting, steering, or queueing and return structured controller results to the command dispatcher.
|
||||
|
||||
A host may reload selected facets when their declared service shape is unchanged; retained consumers keep the same service facades while replacement implementations and singleton snapshots are installed behind them. 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. Server and Session service tokens are non-local and automatically published by their providing host. Presentation-only hookpoints such as `SlashCommands` are explicitly local and never enter an RPC catalogue.
|
||||
|
||||
A facet always calls unqualified `env.use()` or `env.observe()`. The host resolves each token across facet-provided and connected services. The example plugin package contributes `/hello` through its TUI facet. `examples/plugins/pi-example-plugin/` is an actual `@earendil-works/pi-example-plugin` package: repeatable `-e` options select plugin packages as server defaults or for one Session branch, Chord discovers and builds their conventional facets, and the attached presentation receives matching TUI artifacts through `PresentationPlugins`. Other host facets from the same plugin remain separate bundle entries rather than one aggregate plugin object. Transport bindings remain internal machinery rather than part of the facet environment. `ExperimentalClientTui` currently owns terminal rendering, state subscriptions, navigation, and action dispatch directly. The server provider remains directly assembled until its complete host environment exists. The question dialog, diff review, Git, indexing-job, canvas, and rich local TUI service 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.
|
||||
109
third_party/pi/packages/coding-agent/src/experimental/services/agent-controller-provider.ts
vendored
Normal file
109
third_party/pi/packages/coding-agent/src/experimental/services/agent-controller-provider.ts
vendored
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { AgentLane, OperationResultRecord, SuspendedRun } from "@earendil-works/pi-agent-core";
|
||||
import type { ImageContent } from "@earendil-works/pi-ai";
|
||||
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] {
|
||||
return [request.message, request.images ?? undefined];
|
||||
}
|
||||
54
third_party/pi/packages/coding-agent/src/experimental/services/agent-controller.ts
vendored
Normal file
54
third_party/pi/packages/coding-agent/src/experimental/services/agent-controller.ts
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
import { type Context, defineService } from "@earendil-works/chord";
|
||||
|
||||
export interface AgentPromptImage {
|
||||
type: "image";
|
||||
data: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
export interface AgentPromptRequest {
|
||||
message: string;
|
||||
images: AgentPromptImage[] | 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");
|
||||
405
third_party/pi/packages/coding-agent/src/experimental/services/connection.ts
vendored
Normal file
405
third_party/pi/packages/coding-agent/src/experimental/services/connection.ts
vendored
Normal file
@@ -0,0 +1,405 @@
|
||||
import {
|
||||
type Context,
|
||||
createRemoteServiceBinding,
|
||||
type MutableReplicatedState,
|
||||
type RemoteServiceBinding,
|
||||
type RemoteServiceSource,
|
||||
type RemoteServices,
|
||||
type RemoteServiceTransport,
|
||||
type ReplicatedState,
|
||||
replicatedState,
|
||||
type Service,
|
||||
type ServiceCatalogueEntry,
|
||||
} from "@earendil-works/chord";
|
||||
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
||||
import { type Client, createClientServiceTransport } from "@earendil-works/pi-client";
|
||||
import type { SessionTarget } from "@earendil-works/pi-protocol";
|
||||
|
||||
export type ServerConnectionState =
|
||||
| { status: "connecting"; attempt: number }
|
||||
| { status: "connected"; since: string }
|
||||
| { status: "disconnected"; since: string; reason: string; retryAt: string | null };
|
||||
|
||||
export type SessionAttachmentState =
|
||||
| { status: "detached" }
|
||||
| { status: "attaching" | "attached" | "degraded"; sessionId: string };
|
||||
|
||||
export interface ServerServiceSource extends RemoteServiceSource {
|
||||
readonly connection: ReplicatedState<ServerConnectionState>;
|
||||
dispose(context: Context): Promise<void>;
|
||||
}
|
||||
|
||||
export interface SessionServiceSource extends RemoteServiceSource {
|
||||
readonly attachment: ReplicatedState<SessionAttachmentState>;
|
||||
/** Wait for the exact current attachment generation to finish hydrating. */
|
||||
whenAttached(sessionId: string, context: Context): Promise<void>;
|
||||
/** Wait for every binding to finish releasing the previous attachment. */
|
||||
whenDetached(context: Context): Promise<void>;
|
||||
dispose(context: Context): Promise<void>;
|
||||
}
|
||||
|
||||
export interface ServiceSourceOptions {
|
||||
readonly onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
class RoutedServiceBinding implements RemoteServices {
|
||||
readonly #services: RemoteServiceBinding;
|
||||
readonly #getBound: () => boolean;
|
||||
readonly #onActivate: (context: Context) => Promise<void>;
|
||||
readonly #remove: () => void;
|
||||
#activated = false;
|
||||
#activationComplete = false;
|
||||
|
||||
constructor(options: {
|
||||
readonly services: readonly { readonly id: string }[];
|
||||
readonly transport: RemoteServiceTransport;
|
||||
readonly getBound: () => boolean;
|
||||
readonly assertAccess: () => void;
|
||||
readonly onError: (error: Error) => void;
|
||||
readonly onActivate?: (context: Context) => Promise<void>;
|
||||
readonly remove: () => void;
|
||||
}) {
|
||||
this.#services = createRemoteServiceBinding({
|
||||
services: options.services,
|
||||
transport: options.transport,
|
||||
bound: false,
|
||||
assertAccess: options.assertAccess,
|
||||
onError: options.onError,
|
||||
});
|
||||
this.#getBound = options.getBound;
|
||||
this.#onActivate = options.onActivate ?? (() => Promise.resolve());
|
||||
this.#remove = options.remove;
|
||||
}
|
||||
|
||||
use<T>(service: Service<T>): T {
|
||||
return this.#services.use(service);
|
||||
}
|
||||
|
||||
observe<T>(service: Service<T>, handler: (service: T, context: Context) => void | Promise<void>): () => void {
|
||||
return this.#services.observe(service, handler);
|
||||
}
|
||||
|
||||
async ready(context: Context): Promise<void> {
|
||||
if (!this.#activated) {
|
||||
this.#activated = true;
|
||||
await this.#services.rebind(this.#getBound(), context);
|
||||
}
|
||||
await this.serviceReady(context);
|
||||
if (!this.#activationComplete) {
|
||||
await this.#onActivate(context);
|
||||
this.#activationComplete = true;
|
||||
}
|
||||
}
|
||||
|
||||
serviceReady(context: Context): Promise<void> {
|
||||
return this.#services.ready(context);
|
||||
}
|
||||
|
||||
updateBound(bound: boolean, context: Context): Promise<void> {
|
||||
return this.#activated ? this.#services.rebind(bound, context) : Promise.resolve();
|
||||
}
|
||||
|
||||
async dispose(context: Context): Promise<void> {
|
||||
this.#remove();
|
||||
await this.#services.dispose(context);
|
||||
}
|
||||
}
|
||||
|
||||
class ServerServiceSourceImpl implements ServerServiceSource {
|
||||
readonly acceptsUnavailableServices = false;
|
||||
readonly connection: ReplicatedState<ServerConnectionState>;
|
||||
readonly #client: Client;
|
||||
readonly #transport: RemoteServiceTransport;
|
||||
readonly #bindings = new Set<RoutedServiceBinding>();
|
||||
readonly #removeConnectionListener: () => void;
|
||||
readonly #onError: (error: Error) => void;
|
||||
#transition = Promise.resolve();
|
||||
#connectionAttempt: number;
|
||||
#disposed = false;
|
||||
|
||||
constructor(client: Client, options: ServiceSourceOptions) {
|
||||
this.#client = client;
|
||||
this.#transport = createClientServiceTransport(client, () => ({ serverId: client.serverId }));
|
||||
this.#onError = options.onError ?? (() => {});
|
||||
this.#connectionAttempt = client.connectionState === "connecting" ? 1 : 0;
|
||||
const connectionState = replicatedState<ServerConnectionState>(
|
||||
toServerConnectionState(client, this.#connectionAttempt),
|
||||
);
|
||||
this.connection = connectionState;
|
||||
this.#removeConnectionListener = client.onConnectionStateChange(({ state, error }) => {
|
||||
if (state === "connecting") this.#connectionAttempt += 1;
|
||||
publishReplacement(
|
||||
connectionState,
|
||||
toServerConnectionState(client, this.#connectionAttempt, error),
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
this.#transition = this.#transition
|
||||
.then(async () => {
|
||||
const results = await Promise.allSettled(
|
||||
[...this.#bindings].map((binding) => binding.updateBound(state === "connected", BACKGROUND_CONTEXT)),
|
||||
);
|
||||
const failures = results.filter(
|
||||
(result): result is PromiseRejectedResult => result.status === "rejected",
|
||||
);
|
||||
if (failures.length > 0) throw new AggregateError(failures.map(({ reason }) => reason));
|
||||
})
|
||||
.catch((transitionError: unknown) => this.#onError(toError(transitionError)));
|
||||
});
|
||||
}
|
||||
|
||||
catalogue(context: Context) {
|
||||
return this.#client.serviceCatalogue({ serverId: this.#client.serverId }, context.abortSignal);
|
||||
}
|
||||
|
||||
open(options: {
|
||||
readonly services: readonly { readonly id: string }[];
|
||||
assertAccess(): void;
|
||||
onError(error: Error): void;
|
||||
}): RemoteServices {
|
||||
if (this.#disposed) throw new Error("Server service source is disposed");
|
||||
let binding!: RoutedServiceBinding;
|
||||
binding = new RoutedServiceBinding({
|
||||
services: options.services,
|
||||
transport: this.#transport,
|
||||
getBound: () => this.#client.connected,
|
||||
assertAccess: options.assertAccess,
|
||||
onError: options.onError,
|
||||
remove: () => this.#bindings.delete(binding),
|
||||
});
|
||||
this.#bindings.add(binding);
|
||||
return binding;
|
||||
}
|
||||
|
||||
async dispose(context: Context): Promise<void> {
|
||||
if (this.#disposed) return;
|
||||
this.#disposed = true;
|
||||
this.#removeConnectionListener();
|
||||
await this.#transition;
|
||||
const bindings = [...this.#bindings];
|
||||
this.#bindings.clear();
|
||||
const results = await Promise.allSettled(bindings.map((binding) => binding.dispose(context)));
|
||||
throwFailures(results, "Failed to dispose server service source");
|
||||
}
|
||||
}
|
||||
|
||||
class SessionServiceSourceImpl implements SessionServiceSource {
|
||||
readonly attachment: ReplicatedState<SessionAttachmentState>;
|
||||
readonly #client: Client;
|
||||
readonly #transport: RemoteServiceTransport;
|
||||
readonly #attachmentState: MutableReplicatedState<SessionAttachmentState>;
|
||||
readonly #bindings = new Set<RoutedServiceBinding>();
|
||||
readonly #removeAttachmentListener: () => void;
|
||||
readonly #onError: (error: Error) => void;
|
||||
readonly #transitions = new Set<Promise<void>>();
|
||||
#catalogue: readonly ServiceCatalogueEntry[] | undefined;
|
||||
#attachmentRevision = 0;
|
||||
#disposed = false;
|
||||
|
||||
get acceptsUnavailableServices(): boolean {
|
||||
return this.#client.attachment === undefined && this.#catalogue === undefined;
|
||||
}
|
||||
|
||||
constructor(client: Client, options: ServiceSourceOptions) {
|
||||
this.#client = client;
|
||||
this.#transport = createClientServiceTransport(client, () => client.attachment);
|
||||
this.#onError = options.onError ?? (() => {});
|
||||
this.#attachmentState = replicatedState<SessionAttachmentState>(
|
||||
client.attachment === undefined
|
||||
? { status: "detached" }
|
||||
: { status: "attaching", sessionId: client.attachment.sessionId },
|
||||
);
|
||||
this.attachment = this.#attachmentState;
|
||||
this.#removeAttachmentListener = client.onAttachmentChange((attachment) => {
|
||||
const revision = ++this.#attachmentRevision;
|
||||
if (attachment !== undefined) {
|
||||
publishReplacement(
|
||||
this.#attachmentState,
|
||||
{ status: "attaching", sessionId: attachment.sessionId },
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
void this.#client.serviceCatalogue(attachment).then(
|
||||
(catalogue) => {
|
||||
if (this.#attachmentRevision === revision && sameAttachment(this.#client.attachment, attachment)) {
|
||||
this.#catalogue = catalogue;
|
||||
}
|
||||
},
|
||||
(error: unknown) => this.#onError(toError(error)),
|
||||
);
|
||||
}
|
||||
const transition = this.#rebind(attachment !== undefined, BACKGROUND_CONTEXT);
|
||||
this.#transitions.add(transition);
|
||||
void transition.then(
|
||||
() => {
|
||||
this.#transitions.delete(transition);
|
||||
if (this.#attachmentRevision !== revision || !sameAttachment(this.#client.attachment, attachment))
|
||||
return;
|
||||
publishReplacement(
|
||||
this.#attachmentState,
|
||||
attachment === undefined
|
||||
? { status: "detached" }
|
||||
: { status: "attached", sessionId: attachment.sessionId },
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
},
|
||||
(error: unknown) => {
|
||||
this.#transitions.delete(transition);
|
||||
if (this.#attachmentRevision !== revision || !sameAttachment(this.#client.attachment, attachment))
|
||||
return;
|
||||
publishReplacement(
|
||||
this.#attachmentState,
|
||||
attachment === undefined
|
||||
? { status: "detached" }
|
||||
: { status: "degraded", sessionId: attachment.sessionId },
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
this.#onError(toError(error));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async catalogue(context: Context): Promise<readonly ServiceCatalogueEntry[]> {
|
||||
const target = this.#client.attachment;
|
||||
if (target === undefined) return this.#catalogue ?? [];
|
||||
const catalogue = await this.#client.serviceCatalogue(target, context.abortSignal);
|
||||
this.#catalogue = catalogue;
|
||||
return catalogue;
|
||||
}
|
||||
|
||||
open(options: {
|
||||
readonly services: readonly { readonly id: string }[];
|
||||
assertAccess(): void;
|
||||
onError(error: Error): void;
|
||||
}): RemoteServices {
|
||||
if (this.#disposed) throw new Error("Session service source is disposed");
|
||||
let binding!: RoutedServiceBinding;
|
||||
binding = new RoutedServiceBinding({
|
||||
services: options.services,
|
||||
transport: this.#transport,
|
||||
getBound: () => this.#client.attachment !== undefined,
|
||||
onActivate: async (context) => {
|
||||
const attachment = this.#client.attachment;
|
||||
if (attachment === undefined) await this.#whenDetached(this.#attachmentRevision, context);
|
||||
else await this.#whenAttached(attachment, this.#attachmentRevision, context);
|
||||
},
|
||||
assertAccess: options.assertAccess,
|
||||
onError: options.onError,
|
||||
remove: () => this.#bindings.delete(binding),
|
||||
});
|
||||
this.#bindings.add(binding);
|
||||
return binding;
|
||||
}
|
||||
|
||||
async whenAttached(sessionId: string, context: Context): Promise<void> {
|
||||
const attachment = this.#client.attachment;
|
||||
if (attachment === undefined || attachment.sessionId !== sessionId) {
|
||||
throw new Error(`Session ${sessionId} is not the current attachment`);
|
||||
}
|
||||
await this.#whenAttached(attachment, this.#attachmentRevision, context);
|
||||
}
|
||||
|
||||
async whenDetached(context: Context): Promise<void> {
|
||||
if (this.#client.attachment !== undefined) throw new Error("A Session is still attached");
|
||||
await this.#whenDetached(this.#attachmentRevision, context);
|
||||
}
|
||||
|
||||
async dispose(context: Context): Promise<void> {
|
||||
if (this.#disposed) return;
|
||||
this.#disposed = true;
|
||||
this.#removeAttachmentListener();
|
||||
await Promise.allSettled(this.#transitions);
|
||||
const bindings = [...this.#bindings];
|
||||
this.#bindings.clear();
|
||||
const results = await Promise.allSettled(bindings.map((binding) => binding.dispose(context)));
|
||||
throwFailures(results, "Failed to dispose Session service source");
|
||||
}
|
||||
|
||||
async #rebind(bound: boolean, context: Context): Promise<void> {
|
||||
const results = await Promise.allSettled(
|
||||
[...this.#bindings].map((binding) => binding.updateBound(bound, context)),
|
||||
);
|
||||
throwFailures(results, "Failed to rebind Session services");
|
||||
}
|
||||
|
||||
async #whenAttached(attachment: SessionTarget, revision: number, context: Context): Promise<void> {
|
||||
try {
|
||||
await Promise.all([...this.#bindings].map((binding) => binding.serviceReady(context)));
|
||||
} catch (error) {
|
||||
if (this.#attachmentRevision === revision && sameAttachment(this.#client.attachment, attachment)) {
|
||||
publishReplacement(this.#attachmentState, { status: "degraded", sessionId: attachment.sessionId }, context);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (this.#attachmentRevision !== revision || !sameAttachment(this.#client.attachment, attachment)) {
|
||||
throw new Error(`Session ${attachment.sessionId} was replaced while attaching`);
|
||||
}
|
||||
const state = this.#attachmentState.value;
|
||||
if (state.status !== "attached" || state.sessionId !== attachment.sessionId) {
|
||||
publishReplacement(this.#attachmentState, { status: "attached", sessionId: attachment.sessionId }, context);
|
||||
}
|
||||
}
|
||||
|
||||
async #whenDetached(revision: number, context: Context): Promise<void> {
|
||||
await Promise.all([...this.#bindings].map((binding) => binding.serviceReady(context)));
|
||||
if (this.#attachmentRevision !== revision || this.#client.attachment !== undefined) {
|
||||
throw new Error("The Session attachment changed while detaching");
|
||||
}
|
||||
if (this.#attachmentState.value.status !== "detached") {
|
||||
publishReplacement(this.#attachmentState, { status: "detached" }, context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Create the server-scoped remote service source for one presentation client. */
|
||||
export function createServerServiceSource(client: Client, options: ServiceSourceOptions = {}): ServerServiceSource {
|
||||
return new ServerServiceSourceImpl(client, options);
|
||||
}
|
||||
|
||||
/** Create the selected-Session remote service source for one presentation client. */
|
||||
export function createSessionServiceSource(client: Client, options: ServiceSourceOptions = {}): SessionServiceSource {
|
||||
return new SessionServiceSourceImpl(client, options);
|
||||
}
|
||||
|
||||
function publishReplacement<T extends object>(state: MutableReplicatedState<T>, value: T, context: Context): void {
|
||||
const target = state.state as Record<string, unknown>;
|
||||
const replacement = value as Record<string, unknown>;
|
||||
for (const key of Object.keys(target)) {
|
||||
if (!Object.hasOwn(replacement, key)) delete target[key];
|
||||
}
|
||||
Object.assign(target, replacement);
|
||||
state.publish(context);
|
||||
}
|
||||
|
||||
function toServerConnectionState(client: Client, attempt: number, error?: Error): ServerConnectionState {
|
||||
const since = new Date().toISOString();
|
||||
switch (client.connectionState) {
|
||||
case "connecting":
|
||||
return { status: "connecting", attempt };
|
||||
case "connected":
|
||||
return { status: "connected", since };
|
||||
case "disconnected":
|
||||
return {
|
||||
status: "disconnected",
|
||||
since,
|
||||
reason: error?.message ?? "Client is disconnected",
|
||||
retryAt: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function sameAttachment(left: SessionTarget | undefined, right: SessionTarget | undefined): boolean {
|
||||
if (left === undefined || right === undefined) return left === right;
|
||||
return (
|
||||
left.serverId === right.serverId && left.sessionId === right.sessionId && left.attachmentId === right.attachmentId
|
||||
);
|
||||
}
|
||||
|
||||
function throwFailures(results: readonly PromiseSettledResult<unknown>[], message: string): void {
|
||||
const errors = results.flatMap((result) => (result.status === "rejected" ? [result.reason] : []));
|
||||
if (errors.length === 1) throw errors[0];
|
||||
if (errors.length > 1) throw new AggregateError(errors, message);
|
||||
}
|
||||
|
||||
function toError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
143
third_party/pi/packages/coding-agent/src/experimental/services/models-provider.ts
vendored
Normal file
143
third_party/pi/packages/coding-agent/src/experimental/services/models-provider.ts
vendored
Normal file
@@ -0,0 +1,143 @@
|
||||
import { type Context, defineFacet, type Facet, type MutableReplicatedState } from "@earendil-works/chord";
|
||||
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
||||
import type { AgentLane, ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
|
||||
import type { ModelRuntime } from "../../core/model-runtime.ts";
|
||||
import type { SettingsManager } from "../../core/settings-manager.ts";
|
||||
import { Models, type Models as ModelsService, type ModelsState } from "./models.ts";
|
||||
|
||||
export interface ModelsServiceRuntime {
|
||||
readonly service: ModelsService;
|
||||
activate(context: Context): Promise<void>;
|
||||
}
|
||||
|
||||
export function createModelsService(
|
||||
lane: AgentLane,
|
||||
modelRuntime: ModelRuntime | undefined,
|
||||
settingsManager: SettingsManager | undefined,
|
||||
createState: (initial: ModelsState) => MutableReplicatedState<ModelsState>,
|
||||
): ModelsServiceRuntime {
|
||||
let catalogRevision = 0;
|
||||
const state = createState({
|
||||
catalog: { revision: 0, availableModels: [] },
|
||||
configuration: { model: null, thinkingLevel: "off" },
|
||||
refresh: { status: "idle" },
|
||||
});
|
||||
const readConfiguration = async (context: Context): Promise<ModelsState["configuration"]> => {
|
||||
const [selected, thinkingLevel] = await Promise.all([lane.getModel(context), lane.getThinkingLevel(context)]);
|
||||
return {
|
||||
model: selected === undefined ? null : { provider: selected.provider, modelId: selected.id },
|
||||
thinkingLevel,
|
||||
};
|
||||
};
|
||||
const readThinkingLevels = async (context: Context): Promise<ThinkingLevel[]> => {
|
||||
const selected = await lane.getModel(context);
|
||||
return selected === undefined ? ["off"] : getSupportedThinkingLevels(selected);
|
||||
};
|
||||
const readCatalog = async (context: Context): Promise<ModelsState["catalog"]> => {
|
||||
const selected = await lane.getModel(context);
|
||||
const available = modelRuntime?.getAvailableSnapshot() ?? [];
|
||||
const catalog =
|
||||
selected === undefined || includesModel(available, selected) ? available : [...available, selected];
|
||||
catalogRevision += 1;
|
||||
return {
|
||||
revision: catalogRevision,
|
||||
availableModels: catalog.map((model) => ({
|
||||
provider: model.provider,
|
||||
modelId: model.id,
|
||||
name: model.name,
|
||||
reasoning: model.reasoning,
|
||||
})),
|
||||
};
|
||||
};
|
||||
const service: ModelsService = {
|
||||
state,
|
||||
async cycleThinking(context) {
|
||||
const levels = await readThinkingLevels(context);
|
||||
const current = await lane.getThinkingLevel(context);
|
||||
const index = levels.indexOf(current);
|
||||
const next = levels[(index + 1) % levels.length] ?? "off";
|
||||
await lane.setThinkingLevel(next, context);
|
||||
state.state.configuration = await readConfiguration(context);
|
||||
state.publish(context);
|
||||
},
|
||||
async getThinkingLevels(context) {
|
||||
return [...(await readThinkingLevels(context))];
|
||||
},
|
||||
async refresh(context) {
|
||||
state.state.refresh = { status: "refreshing" };
|
||||
state.publish(context);
|
||||
const refresh = modelRuntime?.refresh({ signal: context.abortSignal });
|
||||
if (refresh === undefined) {
|
||||
const [catalog, configuration] = await Promise.all([readCatalog(context), readConfiguration(context)]);
|
||||
state.state.catalog = catalog;
|
||||
state.state.configuration = configuration;
|
||||
state.state.refresh = { status: "done" };
|
||||
state.publish(context);
|
||||
return;
|
||||
}
|
||||
const result = await refresh;
|
||||
const errors = Object.fromEntries([...result.errors].map(([id, error]) => [id, error.message]));
|
||||
const [catalog, configuration] = await Promise.all([readCatalog(context), readConfiguration(context)]);
|
||||
state.state.catalog = catalog;
|
||||
state.state.configuration = configuration;
|
||||
state.state.refresh = Object.keys(errors).length === 0 ? { status: "done" } : { status: "warning", errors };
|
||||
state.publish(context);
|
||||
},
|
||||
async select(model, context) {
|
||||
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);
|
||||
settingsManager?.setDefaultModelAndProvider(selected.provider, selected.id);
|
||||
await settingsManager?.flush();
|
||||
state.state.configuration = await readConfiguration(context);
|
||||
state.publish(context);
|
||||
},
|
||||
async selectThinking(level, context) {
|
||||
const levels = await readThinkingLevels(context);
|
||||
if (!levels.includes(level)) {
|
||||
throw new Error(`Thinking level ${level} is unavailable; choose one of: ${levels.join(", ")}`);
|
||||
}
|
||||
await lane.setThinkingLevel(level, context);
|
||||
state.state.configuration = await readConfiguration(context);
|
||||
state.publish(context);
|
||||
},
|
||||
};
|
||||
return {
|
||||
service,
|
||||
async activate(context) {
|
||||
const [catalog, configuration] = await Promise.all([readCatalog(context), readConfiguration(context)]);
|
||||
state.state.catalog = catalog;
|
||||
state.state.configuration = configuration;
|
||||
state.state.refresh = { status: "idle" };
|
||||
state.publish(context);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createModelsServiceFacet(options: {
|
||||
readonly lane: AgentLane;
|
||||
readonly modelRuntime: ModelRuntime | undefined;
|
||||
readonly settingsManager?: SettingsManager;
|
||||
}): Facet {
|
||||
return defineFacet({
|
||||
id: "@pi/models",
|
||||
setup(env) {
|
||||
const runtime = createModelsService(
|
||||
options.lane,
|
||||
options.modelRuntime,
|
||||
options.settingsManager,
|
||||
env.replicatedState,
|
||||
);
|
||||
env.provide(Models, runtime.service);
|
||||
env.onActivate(() => runtime.activate(BACKGROUND_CONTEXT));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function includesModel(
|
||||
models: readonly { readonly provider: string; readonly id: string }[],
|
||||
selected: { readonly provider: string; readonly id: string },
|
||||
): boolean {
|
||||
return models.some((model) => model.provider === selected.provider && model.id === selected.id);
|
||||
}
|
||||
35
third_party/pi/packages/coding-agent/src/experimental/services/models.ts
vendored
Normal file
35
third_party/pi/packages/coding-agent/src/experimental/services/models.ts
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
import { type Context, defineService, type ReplicatedState } from "@earendil-works/chord";
|
||||
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||
|
||||
export interface ModelRef {
|
||||
provider: string;
|
||||
modelId: string;
|
||||
}
|
||||
|
||||
export interface ModelSummary extends ModelRef {
|
||||
name: string;
|
||||
reasoning: boolean;
|
||||
}
|
||||
|
||||
export interface ModelsState {
|
||||
catalog: {
|
||||
revision: number;
|
||||
availableModels: ModelSummary[];
|
||||
};
|
||||
configuration: {
|
||||
model: ModelRef | null;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
};
|
||||
refresh: { status: "idle" | "refreshing" | "done" } | { status: "warning"; errors: Record<string, string> };
|
||||
}
|
||||
|
||||
export interface Models {
|
||||
readonly state: ReplicatedState<ModelsState>;
|
||||
cycleThinking(context: Context): Promise<void>;
|
||||
getThinkingLevels(context: Context): Promise<ThinkingLevel[]>;
|
||||
refresh(context: Context): Promise<void>;
|
||||
select(model: ModelRef, context: Context): Promise<void>;
|
||||
selectThinking(level: ThinkingLevel, context: Context): Promise<void>;
|
||||
}
|
||||
|
||||
export const Models = defineService<Models>("pi.models");
|
||||
19
third_party/pi/packages/coding-agent/src/experimental/services/plugins.ts
vendored
Normal file
19
third_party/pi/packages/coding-agent/src/experimental/services/plugins.ts
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
import { type Context, defineService, type JsonValue } from "@earendil-works/chord";
|
||||
|
||||
/** Server-built plugin generations available to presentations. */
|
||||
export interface PresentationPlugins {
|
||||
prepareSession(
|
||||
request: { readonly sessionId: string; readonly packagePaths: readonly string[] | null },
|
||||
context: Context,
|
||||
): Promise<JsonValue>;
|
||||
reload(context: Context): Promise<JsonValue>;
|
||||
}
|
||||
|
||||
export const PresentationPlugins = defineService<PresentationPlugins>("pi.presentation-plugins");
|
||||
|
||||
/** Plugin facets hosted in the currently attached Session worker. */
|
||||
export interface SessionPlugins {
|
||||
reload(context: Context): Promise<void>;
|
||||
}
|
||||
|
||||
export const SessionPlugins = defineService<SessionPlugins>("pi.session-plugins");
|
||||
20
third_party/pi/packages/coding-agent/src/experimental/services/presentation-ui.ts
vendored
Normal file
20
third_party/pi/packages/coding-agent/src/experimental/services/presentation-ui.ts
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
import { type Context, defineService } from "@earendil-works/chord";
|
||||
|
||||
export interface PresentationSelectItem {
|
||||
readonly value: string;
|
||||
readonly label: string;
|
||||
readonly description?: string;
|
||||
}
|
||||
|
||||
/** Narrow process-local UI capabilities available to presentation facets. */
|
||||
export interface PresentationUI {
|
||||
select(
|
||||
title: string,
|
||||
items: readonly PresentationSelectItem[],
|
||||
selectedValue: string | undefined,
|
||||
context: Context,
|
||||
): Promise<string | undefined>;
|
||||
showStatus(message: string, context: Context): void;
|
||||
}
|
||||
|
||||
export const PresentationUI = defineService<PresentationUI>("pi.local.presentation-ui", { local: true });
|
||||
150
third_party/pi/packages/coding-agent/src/experimental/services/server.ts
vendored
Normal file
150
third_party/pi/packages/coding-agent/src/experimental/services/server.ts
vendored
Normal file
@@ -0,0 +1,150 @@
|
||||
import {
|
||||
type Context,
|
||||
createRemoteServiceEndpoint,
|
||||
type JsonValue,
|
||||
RemoteServiceProvider,
|
||||
replicatedState,
|
||||
} from "@earendil-works/chord";
|
||||
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
||||
import type { RoutedServerServiceAttachment, RoutedServerServiceHost } from "@earendil-works/pi-server";
|
||||
import { PresentationPlugins } from "./plugins.ts";
|
||||
import {
|
||||
type SessionCreateOptions,
|
||||
SessionDirectory,
|
||||
type SessionDirectoryState,
|
||||
SessionManagement,
|
||||
type SessionSummary,
|
||||
} from "./sessions.ts";
|
||||
|
||||
export interface ExperimentalServerServices {
|
||||
readonly host: RoutedServerServiceHost;
|
||||
refresh(context?: Context): Promise<void>;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function createExperimentalServerServices(options: {
|
||||
list(context: Context): Promise<SessionSummary[]>;
|
||||
create(createOptions: SessionCreateOptions, context: Context): Promise<SessionSummary>;
|
||||
remove(sessionId: string, context: Context): Promise<void>;
|
||||
prepareSessionPlugins(
|
||||
sessionId: string,
|
||||
packagePaths: readonly string[] | undefined,
|
||||
context: Context,
|
||||
): Promise<{ readonly packagePaths: readonly string[]; readonly presentationPlugins: JsonValue }>;
|
||||
reloadPresentationPlugins(packagePaths: readonly string[], context: Context): Promise<JsonValue>;
|
||||
}): Promise<ExperimentalServerServices> {
|
||||
let revision = 1;
|
||||
const directory = replicatedState<SessionDirectoryState>({
|
||||
revision,
|
||||
sessions: await options.list(BACKGROUND_CONTEXT),
|
||||
});
|
||||
const attachments = new Set<RoutedServerServiceAttachment>();
|
||||
let mutationTail = Promise.resolve();
|
||||
|
||||
const refreshNow = async (context: Context): Promise<void> => {
|
||||
const sessions = await options.list(context);
|
||||
revision += 1;
|
||||
directory.state.revision = revision;
|
||||
directory.state.sessions = sessions;
|
||||
directory.publish(context);
|
||||
};
|
||||
const serialize = <T>(operation: () => Promise<T>): Promise<T> => {
|
||||
const result = mutationTail.catch(() => {}).then(operation);
|
||||
mutationTail = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return result;
|
||||
};
|
||||
|
||||
return {
|
||||
host: {
|
||||
attachClient(presentation) {
|
||||
let preparedPluginPackagePaths: readonly string[] | undefined;
|
||||
const provider = new RemoteServiceProvider([
|
||||
{ service: SessionDirectory, mode: "singleton" },
|
||||
{ service: SessionManagement, mode: "singleton" },
|
||||
{ service: PresentationPlugins, mode: "singleton" },
|
||||
]);
|
||||
provider.provide(SessionDirectory, { state: directory });
|
||||
provider.provide(PresentationPlugins, {
|
||||
prepareSession: ({ sessionId, packagePaths }, context) =>
|
||||
serialize(async () => {
|
||||
const selected = await options.prepareSessionPlugins(
|
||||
sessionId,
|
||||
packagePaths ?? undefined,
|
||||
context,
|
||||
);
|
||||
preparedPluginPackagePaths = selected.packagePaths;
|
||||
return selected.presentationPlugins;
|
||||
}),
|
||||
reload: (context) =>
|
||||
serialize(() => {
|
||||
if (preparedPluginPackagePaths === undefined) {
|
||||
throw new Error("No Session plugin selection is prepared");
|
||||
}
|
||||
return options.reloadPresentationPlugins(preparedPluginPackagePaths, context);
|
||||
}),
|
||||
});
|
||||
provider.provide(SessionManagement, {
|
||||
create: (createOptions, context) =>
|
||||
serialize(async () => {
|
||||
const created = await options.create(createOptions, context);
|
||||
await refreshNow(context);
|
||||
return created;
|
||||
}),
|
||||
remove: (sessionId, context) =>
|
||||
serialize(async () => {
|
||||
await presentation.prepareSessionRemoval(sessionId, context);
|
||||
await options.remove(sessionId, context);
|
||||
await refreshNow(context);
|
||||
}),
|
||||
attach: (sessionId, context) =>
|
||||
serialize(async () => {
|
||||
await presentation.attachSession(sessionId, context);
|
||||
}),
|
||||
detach: (context) =>
|
||||
serialize(async () => {
|
||||
await presentation.detachSession(context);
|
||||
preparedPluginPackagePaths = undefined;
|
||||
}),
|
||||
});
|
||||
const attachment = createProviderAttachment(provider, () => attachments.delete(attachment));
|
||||
attachments.add(attachment);
|
||||
return attachment;
|
||||
},
|
||||
},
|
||||
refresh: (context = BACKGROUND_CONTEXT) => serialize(() => refreshNow(context)),
|
||||
async dispose() {
|
||||
const releases = await Promise.allSettled(
|
||||
[...attachments].map((attachment) => attachment.release(BACKGROUND_CONTEXT)),
|
||||
);
|
||||
attachments.clear();
|
||||
await mutationTail;
|
||||
const errors = releases.flatMap((result) => (result.status === "rejected" ? [result.reason] : []));
|
||||
if (errors.length === 1) throw errors[0];
|
||||
if (errors.length > 1) throw new AggregateError(errors, "Failed to release server service attachments");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createProviderAttachment(
|
||||
provider: RemoteServiceProvider,
|
||||
onRelease: () => void,
|
||||
): RoutedServerServiceAttachment {
|
||||
const endpoint = createRemoteServiceEndpoint(provider);
|
||||
let released = false;
|
||||
return {
|
||||
invokeService(call, publish, context) {
|
||||
if (released) return Promise.reject(new Error("Server service attachment is released"));
|
||||
return endpoint.invoke(call, publish, context);
|
||||
},
|
||||
release() {
|
||||
if (released) return;
|
||||
released = true;
|
||||
endpoint.dispose();
|
||||
provider.dispose();
|
||||
onRelease();
|
||||
},
|
||||
};
|
||||
}
|
||||
35
third_party/pi/packages/coding-agent/src/experimental/services/sessions.ts
vendored
Normal file
35
third_party/pi/packages/coding-agent/src/experimental/services/sessions.ts
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
import { type Context, defineService, type ReplicatedState } from "@earendil-works/chord";
|
||||
import type { ServerId } from "@earendil-works/pi-protocol";
|
||||
|
||||
export interface SessionAddress {
|
||||
serverId: ServerId;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export interface SessionSummary extends SessionAddress {
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface SessionCreateOptions {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface SessionDirectoryState {
|
||||
revision: number;
|
||||
sessions: SessionSummary[];
|
||||
}
|
||||
|
||||
export interface SessionDirectory {
|
||||
readonly state: ReplicatedState<SessionDirectoryState>;
|
||||
}
|
||||
|
||||
export const SessionDirectory = defineService<SessionDirectory>("pi.session-directory");
|
||||
|
||||
export interface SessionManagement {
|
||||
create(options: SessionCreateOptions, context: Context): Promise<SessionSummary>;
|
||||
remove(sessionId: string, context: Context): Promise<void>;
|
||||
attach(sessionId: string, context: Context): Promise<void>;
|
||||
detach(context: Context): Promise<void>;
|
||||
}
|
||||
|
||||
export const SessionManagement = defineService<SessionManagement>("pi.session-management");
|
||||
225
third_party/pi/packages/coding-agent/src/experimental/services/slash-commands-provider.ts
vendored
Normal file
225
third_party/pi/packages/coding-agent/src/experimental/services/slash-commands-provider.ts
vendored
Normal file
@@ -0,0 +1,225 @@
|
||||
import { defineFacet, type Facet, type JsonValue } from "@earendil-works/chord";
|
||||
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||
import { AgentController } from "./agent-controller.ts";
|
||||
import { type ModelSummary, Models, type Models as ModelsService } from "./models.ts";
|
||||
import { PresentationPlugins, SessionPlugins } from "./plugins.ts";
|
||||
import { PresentationUI } from "./presentation-ui.ts";
|
||||
import { type SlashCommandContribution, SlashCommands } from "./slash-commands.ts";
|
||||
|
||||
const THINKING_DESCRIPTIONS: Record<ThinkingLevel, string> = {
|
||||
off: "No reasoning",
|
||||
minimal: "Very brief reasoning",
|
||||
low: "Light reasoning",
|
||||
medium: "Moderate reasoning",
|
||||
high: "Deep reasoning",
|
||||
xhigh: "Extra-high reasoning",
|
||||
max: "Maximum reasoning",
|
||||
};
|
||||
|
||||
interface RegisteredSlashCommand {
|
||||
readonly command: SlashCommandContribution;
|
||||
closed: boolean;
|
||||
}
|
||||
|
||||
export class SlashCommandRegistry implements SlashCommands {
|
||||
readonly #commands = new Map<string, RegisteredSlashCommand[]>();
|
||||
readonly #listeners = new Set<(commands: readonly SlashCommandContribution[]) => void>();
|
||||
|
||||
register(command: SlashCommandContribution): () => void {
|
||||
this.#validate(command);
|
||||
if (this.#commands.has(command.name)) throw new Error(`Slash command /${command.name} is already registered`);
|
||||
return this.#add(command);
|
||||
}
|
||||
|
||||
replace(command: SlashCommandContribution): () => void {
|
||||
this.#validate(command);
|
||||
return this.#add(command);
|
||||
}
|
||||
|
||||
list(): readonly SlashCommandContribution[] {
|
||||
return Object.freeze([...this.#commands.values()].map((entries) => entries[0]!.command));
|
||||
}
|
||||
|
||||
subscribe(listener: (commands: readonly SlashCommandContribution[]) => void): () => void {
|
||||
this.#listeners.add(listener);
|
||||
listener(this.list());
|
||||
return () => this.#listeners.delete(listener);
|
||||
}
|
||||
|
||||
#add(command: SlashCommandContribution): () => void {
|
||||
const entry: RegisteredSlashCommand = { command: Object.freeze({ ...command }), closed: false };
|
||||
const entries = this.#commands.get(command.name) ?? [];
|
||||
entries.push(entry);
|
||||
this.#commands.set(command.name, entries);
|
||||
if (entries.length === 1) this.#publish();
|
||||
return () => {
|
||||
if (entry.closed) return;
|
||||
entry.closed = true;
|
||||
if (entries[0] !== entry) return;
|
||||
while (entries[0]?.closed) entries.shift();
|
||||
if (entries.length === 0) this.#commands.delete(command.name);
|
||||
this.#publish();
|
||||
};
|
||||
}
|
||||
|
||||
#validate(command: SlashCommandContribution): void {
|
||||
if (!/^[a-z0-9][a-z0-9:-]*$/u.test(command.name)) {
|
||||
throw new TypeError(`Invalid slash command name: ${command.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
#publish(): void {
|
||||
const commands = this.list();
|
||||
for (const listener of this.#listeners) listener(commands);
|
||||
}
|
||||
}
|
||||
|
||||
export function createSlashCommandsRuntimeFacet(registry = new SlashCommandRegistry()): Facet {
|
||||
return defineFacet({
|
||||
id: "@pi/slash-commands-runtime",
|
||||
setup(env) {
|
||||
env.provide(SlashCommands, registry);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createBuiltInSlashCommandsFacet(options: {
|
||||
reloadPresentationPlugins(data: JsonValue): Promise<void>;
|
||||
}): Facet {
|
||||
return defineFacet({
|
||||
id: "@pi/slash-commands-builtin",
|
||||
setup(env) {
|
||||
const commands = env.use(SlashCommands);
|
||||
const models = env.use(Models);
|
||||
const controller = env.use(AgentController);
|
||||
const ui = env.use(PresentationUI);
|
||||
const presentationPlugins = env.use(PresentationPlugins);
|
||||
const sessionPlugins = env.use(SessionPlugins);
|
||||
env.onActivate(() => {
|
||||
env.own(commands.replace(modelCommand(models, ui)));
|
||||
env.own(commands.replace(thinkingCommand(models, ui)));
|
||||
env.own(commands.replace(compactCommand(controller, ui)));
|
||||
env.own(
|
||||
commands.replace({
|
||||
name: "reload",
|
||||
description: "Reload server-selected plugins",
|
||||
async run(_args, context) {
|
||||
ui.showStatus("Reloading plugins…", context);
|
||||
const data = await presentationPlugins.reload(context);
|
||||
await sessionPlugins.reload(context);
|
||||
await options.reloadPresentationPlugins(data);
|
||||
ui.showStatus("Reloaded plugins.", context);
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function modelCommand(models: ModelsService, ui: PresentationUI): SlashCommandContribution {
|
||||
return {
|
||||
name: "model",
|
||||
description: "Select model",
|
||||
argumentHint: "<provider/model>",
|
||||
getArgumentCompletions(prefix) {
|
||||
const normalized = prefix.toLowerCase();
|
||||
return (models.state.value?.catalog.availableModels ?? [])
|
||||
.filter((model) => `${model.provider}/${model.modelId} ${model.name}`.toLowerCase().includes(normalized))
|
||||
.map((model) => ({
|
||||
value: `${model.provider}/${model.modelId}`,
|
||||
label: model.modelId,
|
||||
description: model.provider,
|
||||
}));
|
||||
},
|
||||
async run(args, context) {
|
||||
const state = models.state.value;
|
||||
if (state === undefined) throw new Error("Models service is not ready");
|
||||
let selected = exactModel(state.catalog.availableModels, args);
|
||||
if (args.length > 0 && selected === undefined) {
|
||||
throw new Error(`Unknown model: ${args}`);
|
||||
}
|
||||
if (selected === undefined) {
|
||||
const value = await ui.select(
|
||||
"Select model:",
|
||||
state.catalog.availableModels.map((model) => ({
|
||||
value: `${model.provider}/${model.modelId}`,
|
||||
label:
|
||||
state.configuration.model?.provider === model.provider &&
|
||||
state.configuration.model.modelId === model.modelId
|
||||
? `${model.name} (selected)`
|
||||
: model.name,
|
||||
description: `${model.provider}/${model.modelId}`,
|
||||
})),
|
||||
state.configuration.model === null
|
||||
? undefined
|
||||
: `${state.configuration.model.provider}/${state.configuration.model.modelId}`,
|
||||
context,
|
||||
);
|
||||
if (value === undefined) return undefined;
|
||||
selected = exactModel(state.catalog.availableModels, value);
|
||||
if (selected === undefined) throw new Error(`Unknown model: ${value}`);
|
||||
}
|
||||
await models.select({ provider: selected.provider, modelId: selected.modelId }, context);
|
||||
ui.showStatus(`Selected ${selected.provider}/${selected.modelId}.`, context);
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function thinkingCommand(models: ModelsService, ui: PresentationUI): SlashCommandContribution {
|
||||
return {
|
||||
name: "thinking",
|
||||
description: "Set thinking level",
|
||||
argumentHint: "<level>",
|
||||
async run(args, context) {
|
||||
const levels = await models.getThinkingLevels(context);
|
||||
let selected = levels.find((level) => level === args.toLowerCase());
|
||||
if (args.length > 0 && selected === undefined) {
|
||||
throw new Error(`Unknown thinking level "${args}". Available levels: ${levels.join(", ")}.`);
|
||||
}
|
||||
if (selected === undefined) {
|
||||
const value = await ui.select(
|
||||
"Select thinking level:",
|
||||
levels.map((level) => ({
|
||||
value: level,
|
||||
label: models.state.value?.configuration.thinkingLevel === level ? `${level} (selected)` : level,
|
||||
description: THINKING_DESCRIPTIONS[level],
|
||||
})),
|
||||
models.state.value?.configuration.thinkingLevel,
|
||||
context,
|
||||
);
|
||||
if (value === undefined) return undefined;
|
||||
selected = levels.find((level) => level === value);
|
||||
if (selected === undefined) throw new Error(`Unknown thinking level: ${value}`);
|
||||
}
|
||||
await models.selectThinking(selected, context);
|
||||
ui.showStatus(`Thinking level: ${selected}.`, context);
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function compactCommand(controller: AgentController, ui: PresentationUI): SlashCommandContribution {
|
||||
return {
|
||||
name: "compact",
|
||||
description: "Manually compact the session context",
|
||||
argumentHint: "<instructions>",
|
||||
run(args, context) {
|
||||
ui.showStatus("Compacting…", context);
|
||||
return controller.compact({ customInstructions: args.length === 0 ? null : args }, context);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function exactModel(models: readonly ModelSummary[], query: string): ModelSummary | undefined {
|
||||
if (query.length === 0) return undefined;
|
||||
const normalized = query.toLowerCase();
|
||||
const matches = models.filter(
|
||||
(model) =>
|
||||
`${model.provider}/${model.modelId}`.toLowerCase() === normalized ||
|
||||
model.modelId.toLowerCase() === normalized,
|
||||
);
|
||||
return matches.length === 1 ? matches[0] : undefined;
|
||||
}
|
||||
30
third_party/pi/packages/coding-agent/src/experimental/services/slash-commands.ts
vendored
Normal file
30
third_party/pi/packages/coding-agent/src/experimental/services/slash-commands.ts
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
import { type Context, defineService } from "@earendil-works/chord";
|
||||
import type { AgentOperationResponse, AgentQueueResponse } from "./agent-controller.ts";
|
||||
|
||||
export interface SlashCommandCompletion {
|
||||
readonly value: string;
|
||||
readonly label: string;
|
||||
readonly description?: string;
|
||||
}
|
||||
|
||||
export type SlashCommandRunResult = AgentOperationResponse | AgentQueueResponse | undefined;
|
||||
|
||||
export interface SlashCommandContribution {
|
||||
readonly name: string;
|
||||
readonly description?: string;
|
||||
readonly argumentHint?: string;
|
||||
getArgumentCompletions?(
|
||||
argumentPrefix: string,
|
||||
): readonly SlashCommandCompletion[] | null | Promise<readonly SlashCommandCompletion[] | null>;
|
||||
run(args: string, context: Context): SlashCommandRunResult | Promise<SlashCommandRunResult>;
|
||||
}
|
||||
|
||||
export interface SlashCommands {
|
||||
register(command: SlashCommandContribution): () => void;
|
||||
/** Stage a same-name replacement while the previous facet generation retires. */
|
||||
replace(command: SlashCommandContribution): () => void;
|
||||
list(): readonly SlashCommandContribution[];
|
||||
subscribe(listener: (commands: readonly SlashCommandContribution[]) => void): () => void;
|
||||
}
|
||||
|
||||
export const SlashCommands = defineService<SlashCommands>("pi.local.slash-commands", { local: true });
|
||||
128
third_party/pi/packages/coding-agent/src/experimental/services/transcript-provider.ts
vendored
Normal file
128
third_party/pi/packages/coding-agent/src/experimental/services/transcript-provider.ts
vendored
Normal file
@@ -0,0 +1,128 @@
|
||||
import { defineFacet, type Facet, type MutableReplicatedState } from "@earendil-works/chord";
|
||||
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
||||
import {
|
||||
type AgentLane,
|
||||
type HarnessEvent,
|
||||
type LaneSnapshot,
|
||||
type LaneTranscriptSnapshot,
|
||||
type LaneWatchEvent,
|
||||
reduceLaneSnapshot,
|
||||
type WatchHandle,
|
||||
} from "@earendil-works/pi-agent-core";
|
||||
import { Transcript, type Transcript as TranscriptService, type TranscriptState } from "./transcript.ts";
|
||||
|
||||
interface TranscriptRuntime {
|
||||
readonly service: TranscriptService;
|
||||
activate(): Promise<void>;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
export function createTranscriptService(
|
||||
lane: AgentLane,
|
||||
createState: (initial: TranscriptState) => MutableReplicatedState<TranscriptState>,
|
||||
): TranscriptRuntime {
|
||||
const state = createState({ snapshot: null, event: null });
|
||||
let watch: WatchHandle<LaneSnapshot> | undefined;
|
||||
let rebase: Promise<void> | undefined;
|
||||
let rebaseError: Error | undefined;
|
||||
|
||||
const publishSnapshot = (
|
||||
next: LaneSnapshot,
|
||||
event: LaneWatchEvent | null,
|
||||
context: Parameters<typeof state.publish>[0],
|
||||
): void => {
|
||||
state.state.snapshot = next as LaneTranscriptSnapshot;
|
||||
state.state.event = event;
|
||||
state.publish(context);
|
||||
};
|
||||
|
||||
const scheduleRebase = (context: Parameters<typeof state.publish>[0]): void => {
|
||||
if (rebase !== undefined) return;
|
||||
const activeWatch = watch;
|
||||
if (activeWatch === undefined) return;
|
||||
const pending = (async () => {
|
||||
const refreshed = await activeWatch.resnapshot(context);
|
||||
publishSnapshot(refreshed, null, context);
|
||||
})();
|
||||
rebase = pending;
|
||||
void pending.then(
|
||||
() => {
|
||||
if (rebase === pending) rebase = undefined;
|
||||
},
|
||||
(error: unknown) => {
|
||||
rebaseError = error instanceof Error ? error : new Error(String(error));
|
||||
if (rebase === pending) rebase = undefined;
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const onEvent = (event: HarnessEvent, context: Parameters<typeof state.publish>[0]): void => {
|
||||
if (rebaseError !== undefined) throw rebaseError;
|
||||
const forwarded = toLaneWatchEvent(event);
|
||||
if (forwarded === undefined) return;
|
||||
const snapshot = state.state.snapshot;
|
||||
if (snapshot === null) throw new Error("Transcript service is not active");
|
||||
if (reduceLaneSnapshot(snapshot, event) === "rebase") scheduleRebase(context);
|
||||
state.state.event = forwarded;
|
||||
state.publish(context);
|
||||
};
|
||||
|
||||
return {
|
||||
service: { state },
|
||||
async activate() {
|
||||
if (watch !== undefined) throw new Error("Transcript service is already active");
|
||||
const opened = await lane.watch(BACKGROUND_CONTEXT);
|
||||
watch = opened;
|
||||
publishSnapshot(opened.snapshot, null, BACKGROUND_CONTEXT);
|
||||
opened.start(onEvent);
|
||||
},
|
||||
async dispose() {
|
||||
let failure: unknown;
|
||||
try {
|
||||
await rebase;
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
watch?.unsubscribe();
|
||||
watch = undefined;
|
||||
if (failure !== undefined) throw failure;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createTranscriptServiceFacet(lane: AgentLane): Facet {
|
||||
return defineFacet({
|
||||
id: "@pi/transcript",
|
||||
setup(env) {
|
||||
const runtime = createTranscriptService(lane, env.replicatedState);
|
||||
env.provide(Transcript, runtime.service);
|
||||
env.onActivate(() => runtime.activate());
|
||||
env.own(() => runtime.dispose());
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function toLaneWatchEvent(event: HarnessEvent): LaneWatchEvent | undefined {
|
||||
switch (event.type) {
|
||||
case "handler_error":
|
||||
case "turn_start":
|
||||
case "turn_end":
|
||||
case "value_update":
|
||||
case "lane_created":
|
||||
return undefined;
|
||||
case "config_update":
|
||||
if (event.property !== "model" && event.property !== "thinkingLevel" && event.property !== "activeTools") {
|
||||
return undefined;
|
||||
}
|
||||
return event as LaneWatchEvent;
|
||||
case "message_update": {
|
||||
if (event.message.role !== "assistant") {
|
||||
throw new TypeError("Harness message_update did not carry an assistant message");
|
||||
}
|
||||
const { event: _providerEvent, ...update } = event;
|
||||
return update as LaneWatchEvent;
|
||||
}
|
||||
default:
|
||||
return event as LaneWatchEvent;
|
||||
}
|
||||
}
|
||||
15
third_party/pi/packages/coding-agent/src/experimental/services/transcript.ts
vendored
Normal file
15
third_party/pi/packages/coding-agent/src/experimental/services/transcript.ts
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineService, type ReplicatedState } from "@earendil-works/chord";
|
||||
import type { LaneTranscriptSnapshot, LaneWatchEvent } from "@earendil-works/pi-agent-core";
|
||||
|
||||
export interface TranscriptState {
|
||||
snapshot: LaneTranscriptSnapshot | null;
|
||||
/** The source event is retained for presentation side effects; hydration does not replay it. */
|
||||
event: LaneWatchEvent | null;
|
||||
}
|
||||
|
||||
/** Coherent main-lane state replicated through Chord's operation stream. */
|
||||
export interface Transcript {
|
||||
readonly state: ReplicatedState<TranscriptState>;
|
||||
}
|
||||
|
||||
export const Transcript = defineService<Transcript>("pi.transcript");
|
||||
152
third_party/pi/packages/coding-agent/src/experimental/services/worker.ts
vendored
Normal file
152
third_party/pi/packages/coding-agent/src/experimental/services/worker.ts
vendored
Normal file
@@ -0,0 +1,152 @@
|
||||
import {
|
||||
type Context,
|
||||
createFacetHost,
|
||||
createRemoteServiceEndpoint,
|
||||
createStaticFacetLoader,
|
||||
defineFacet,
|
||||
type FacetHost,
|
||||
type FacetLoader,
|
||||
type JsonValue,
|
||||
type RemoteServiceEndpoint,
|
||||
type ServiceCall,
|
||||
type ServiceProviderUpdate,
|
||||
} from "@earendil-works/chord";
|
||||
import type { AgentHarness, AgentLane } from "@earendil-works/pi-agent-core";
|
||||
import type { ModelRuntime } from "../../core/model-runtime.ts";
|
||||
import type { SettingsManager } from "../../core/settings-manager.ts";
|
||||
import { AgentController } from "./agent-controller.ts";
|
||||
import { createAgentController } from "./agent-controller-provider.ts";
|
||||
import { createModelsServiceFacet } from "./models-provider.ts";
|
||||
import { SessionPlugins } from "./plugins.ts";
|
||||
import { createTranscriptServiceFacet } from "./transcript-provider.ts";
|
||||
|
||||
export interface SessionWorkerRuntime {
|
||||
readonly harness: AgentHarness;
|
||||
readonly lane?: AgentLane;
|
||||
readonly modelRuntime?: ModelRuntime;
|
||||
readonly settingsManager?: SettingsManager;
|
||||
readonly facetLoader?: FacetLoader;
|
||||
}
|
||||
|
||||
export interface WorkerServiceScope {
|
||||
readonly serverConnectionId: string;
|
||||
readonly attachmentId: string;
|
||||
}
|
||||
|
||||
interface ScopedServiceEndpoint {
|
||||
readonly scope: WorkerServiceScope;
|
||||
readonly endpoint: RemoteServiceEndpoint;
|
||||
}
|
||||
|
||||
export interface SessionWorkerServices {
|
||||
invoke(call: ServiceCall, scope: WorkerServiceScope, context: Context): Promise<JsonValue | undefined>;
|
||||
removeSubscriptions(matches: (scope: WorkerServiceScope) => boolean): void;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function createSessionWorkerServices(options: {
|
||||
readonly lane: AgentLane;
|
||||
readonly modelRuntime: ModelRuntime | undefined;
|
||||
readonly settingsManager?: SettingsManager;
|
||||
readonly facetLoader?: FacetLoader;
|
||||
publish(scope: WorkerServiceScope, subscriptionId: string, update: ServiceProviderUpdate): Promise<void>;
|
||||
}): Promise<SessionWorkerServices> {
|
||||
const agentControllerRuntimeFacet = defineFacet({
|
||||
id: "@pi/agent-controller-runtime",
|
||||
setup(env) {
|
||||
env.provide(AgentController, createAgentController(options.lane));
|
||||
},
|
||||
});
|
||||
let reloadPlugins = (): Promise<void> => Promise.reject(new Error("Session plugins are not ready"));
|
||||
const pluginRuntimeFacet = defineFacet({
|
||||
id: "@pi/session-plugins-runtime",
|
||||
setup(env) {
|
||||
env.provide(SessionPlugins, { reload: () => reloadPlugins() });
|
||||
},
|
||||
});
|
||||
const builtins = await createStaticFacetLoader([
|
||||
agentControllerRuntimeFacet,
|
||||
pluginRuntimeFacet,
|
||||
createModelsServiceFacet(options),
|
||||
createTranscriptServiceFacet(options.lane),
|
||||
]).load();
|
||||
const pluginLoader = options.facetLoader ?? createStaticFacetLoader([]);
|
||||
let loadedPlugins = await pluginLoader.load();
|
||||
let facetHost: FacetHost;
|
||||
try {
|
||||
facetHost = await createFacetHost({ facets: [...builtins.facets, ...loadedPlugins.facets] });
|
||||
} catch (error) {
|
||||
const cleanup = await Promise.allSettled([loadedPlugins.dispose(), builtins.dispose()]);
|
||||
const cleanupErrors = cleanup.flatMap((result) => (result.status === "rejected" ? [result.reason] : []));
|
||||
if (cleanupErrors.length > 0) {
|
||||
throw new AggregateError([error, ...cleanupErrors], "Session facets failed to start and clean up");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
let reloadTail = Promise.resolve();
|
||||
reloadPlugins = () => {
|
||||
const operation = reloadTail.then(async () => {
|
||||
const candidate = await pluginLoader.load();
|
||||
try {
|
||||
await facetHost.reload(candidate.facets);
|
||||
} catch (error) {
|
||||
try {
|
||||
await candidate.dispose();
|
||||
} catch (cleanupError) {
|
||||
throw new AggregateError([error, cleanupError], "Session plugin reload and cleanup failed");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const retired = loadedPlugins;
|
||||
loadedPlugins = candidate;
|
||||
await retired.dispose();
|
||||
});
|
||||
reloadTail = operation.catch(() => {});
|
||||
return operation;
|
||||
};
|
||||
const provider = facetHost.services;
|
||||
|
||||
const endpoints = new Map<string, ScopedServiceEndpoint>();
|
||||
const removeSubscriptions = (matches: (scope: WorkerServiceScope) => boolean): void => {
|
||||
for (const [key, entry] of endpoints) {
|
||||
if (!matches(entry.scope)) continue;
|
||||
entry.endpoint.dispose();
|
||||
endpoints.delete(key);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
invoke(call, scope, context) {
|
||||
const key = serviceScopeKey(scope);
|
||||
let entry = endpoints.get(key);
|
||||
if (entry === undefined) {
|
||||
entry = { scope, endpoint: createRemoteServiceEndpoint(provider) };
|
||||
endpoints.set(key, entry);
|
||||
}
|
||||
return entry.endpoint.invoke(
|
||||
call,
|
||||
(subscriptionId, update) => options.publish(scope, subscriptionId, update),
|
||||
context,
|
||||
);
|
||||
},
|
||||
removeSubscriptions,
|
||||
async dispose() {
|
||||
removeSubscriptions(() => true);
|
||||
await reloadTail;
|
||||
const errors: unknown[] = [];
|
||||
try {
|
||||
await facetHost.dispose();
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
const results = await Promise.allSettled([loadedPlugins.dispose(), builtins.dispose()]);
|
||||
errors.push(...results.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])));
|
||||
if (errors.length === 1) throw errors[0];
|
||||
if (errors.length > 1) throw new AggregateError(errors, "Failed to dispose Session facets");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function serviceScopeKey(scope: WorkerServiceScope): string {
|
||||
return `${scope.serverConnectionId}\0${scope.attachmentId}`;
|
||||
}
|
||||
859
third_party/pi/packages/coding-agent/src/experimental/session-worker-manager.ts
vendored
Normal file
859
third_party/pi/packages/coding-agent/src/experimental/session-worker-manager.ts
vendored
Normal file
@@ -0,0 +1,859 @@
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { isAbsolute } from "node:path";
|
||||
import {
|
||||
createServiceUnsubscribeCall,
|
||||
decodeServiceControlCall,
|
||||
type JsonValue,
|
||||
parseServiceProviderUpdate,
|
||||
type ServiceCall,
|
||||
type ServiceProviderUpdate,
|
||||
} from "@earendil-works/chord";
|
||||
import {
|
||||
BACKGROUND_CONTEXT,
|
||||
type Context,
|
||||
type JsonlSessionMetadata,
|
||||
TODO_CONTEXT,
|
||||
} from "@earendil-works/pi-agent-core";
|
||||
import { type RoutedSessionAttachment, type RoutedSessionHandle, ServerError } from "@earendil-works/pi-server";
|
||||
import { Check } from "typebox/value";
|
||||
import type { CoordinatorConnection, CoordinatorConnectionEvent } from "./coordinator.ts";
|
||||
import { spawnInternalProcess } from "./process.ts";
|
||||
import {
|
||||
SESSION_WORKER_CONTROL_ADDRESS_ENV,
|
||||
SESSION_WORKER_CONTROL_TOKEN_ENV,
|
||||
SESSION_WORKER_PEER_ID_ENV,
|
||||
SESSION_WORKER_SESSION_KEY_ENV,
|
||||
type SessionWorkerEvent,
|
||||
SessionWorkerEventSchema,
|
||||
type SessionWorkerOptions,
|
||||
type WorkerOperationResponse,
|
||||
type WorkerOperationScope,
|
||||
} from "./session-worker.ts";
|
||||
|
||||
const WORKER_STARTUP_TIMEOUT_MS = 15_000;
|
||||
const WORKER_SHUTDOWN_TIMEOUT_MS = 10_000;
|
||||
const WORKER_DISCOVERY_TIMEOUT_MS = 5_000;
|
||||
const WORKER_DEMAND_TIMEOUT_MS = 5_000;
|
||||
|
||||
export class SessionPluginSelectionConflictError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "SessionPluginSelectionConflictError";
|
||||
}
|
||||
}
|
||||
|
||||
interface WorkerRecord {
|
||||
readonly peerId: string;
|
||||
readonly metadata: JsonlSessionMetadata;
|
||||
readonly pid: number;
|
||||
readonly token: string;
|
||||
readonly pluginManifestPaths: readonly string[];
|
||||
readonly terminated: Promise<Error | undefined>;
|
||||
resolveTerminated(error: Error | undefined): void;
|
||||
readonly attachmentIds: Set<string>;
|
||||
expectedStop: boolean;
|
||||
stopPromise?: Promise<void>;
|
||||
stopping: boolean;
|
||||
}
|
||||
|
||||
interface PendingDemand {
|
||||
readonly attachmentId: string;
|
||||
readonly attached: boolean;
|
||||
readonly requestId: string;
|
||||
readonly timer: NodeJS.Timeout;
|
||||
readonly worker: WorkerRecord;
|
||||
resolve(): void;
|
||||
reject(error: Error): void;
|
||||
}
|
||||
|
||||
interface PendingWorkerOperation {
|
||||
readonly worker: WorkerRecord;
|
||||
readonly scope: WorkerOperationScope;
|
||||
cleanup(): void;
|
||||
resolve(result: JsonValue | undefined): void;
|
||||
reject(error: Error): void;
|
||||
}
|
||||
|
||||
interface WorkerServiceSubscription {
|
||||
readonly worker: WorkerRecord;
|
||||
readonly scope: WorkerOperationScope;
|
||||
readonly listener: (update: ServiceProviderUpdate, context: Context) => void | Promise<void>;
|
||||
deliveryTail: Promise<void>;
|
||||
}
|
||||
|
||||
interface PendingLaunch {
|
||||
readonly sessionKey: string;
|
||||
readonly peerId: string;
|
||||
readonly token: string;
|
||||
readonly pluginManifestPaths: readonly string[];
|
||||
readonly child: ChildProcess;
|
||||
readonly timer: NodeJS.Timeout;
|
||||
readonly promise: Promise<WorkerRecord>;
|
||||
resolve(worker: WorkerRecord): void;
|
||||
reject(error: Error): void;
|
||||
}
|
||||
|
||||
/** Session and process bookkeeping owned by one replaceable server process. */
|
||||
export class SessionWorkerManager {
|
||||
readonly workerPids = new Map<string, number>();
|
||||
readonly #coordinator: Pick<
|
||||
CoordinatorConnection,
|
||||
"controlPath" | "serverConnectionId" | "wasReplaced" | "onEvent" | "send" | "broadcast"
|
||||
>;
|
||||
readonly #sessionDir: string;
|
||||
readonly #model: { readonly provider?: string; readonly model: string } | undefined;
|
||||
readonly #workersBySession = new Map<string, WorkerRecord>();
|
||||
readonly #workersByPeer = new Map<string, WorkerRecord>();
|
||||
readonly #pending = new Map<string, PendingLaunch>();
|
||||
readonly #pendingDemand = new Map<string, PendingDemand>();
|
||||
readonly #pendingOperations = new Map<string, PendingWorkerOperation>();
|
||||
readonly #serviceSubscriptions = new Map<string, WorkerServiceSubscription>();
|
||||
readonly #removeListener: () => void;
|
||||
readonly #onWorkerCountChanged: ((count: number) => void) | undefined;
|
||||
#discoveryPeers?: Set<string>;
|
||||
#resolveDiscovery?: () => void;
|
||||
#detached = false;
|
||||
#shuttingDown = false;
|
||||
|
||||
constructor(
|
||||
coordinator: Pick<
|
||||
CoordinatorConnection,
|
||||
"controlPath" | "serverConnectionId" | "wasReplaced" | "onEvent" | "send" | "broadcast"
|
||||
>,
|
||||
sessionDir: string,
|
||||
model?: { readonly provider?: string; readonly model: string },
|
||||
onWorkerCountChanged?: (count: number) => void,
|
||||
) {
|
||||
this.#coordinator = coordinator;
|
||||
this.#sessionDir = sessionDir;
|
||||
this.#model = model;
|
||||
this.#onWorkerCountChanged = onWorkerCountChanged;
|
||||
this.#removeListener = coordinator.onEvent((event) => this.#handleCoordinatorEvent(event));
|
||||
}
|
||||
|
||||
get trackedSessions(): readonly JsonlSessionMetadata[] {
|
||||
return [...this.#workersBySession.values()].map((worker) => worker.metadata);
|
||||
}
|
||||
|
||||
assertSessionPluginManifestPaths(metadata: JsonlSessionMetadata, manifestPaths: readonly string[]): void {
|
||||
const existing = this.#workersBySession.get(metadata.path);
|
||||
if (existing !== undefined && !sameStrings(existing.pluginManifestPaths, manifestPaths)) {
|
||||
throw new SessionPluginSelectionConflictError(
|
||||
`Session ${metadata.id} is active with a different plugin selection`,
|
||||
);
|
||||
}
|
||||
const pending = this.#pending.get(metadata.path);
|
||||
if (pending !== undefined && !sameStrings(pending.pluginManifestPaths, manifestPaths)) {
|
||||
throw new SessionPluginSelectionConflictError(
|
||||
`Session ${metadata.id} is starting with a different plugin selection`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async discover(peerIds: ReadonlySet<string>): Promise<void> {
|
||||
if (this.#detached) return;
|
||||
const undiscovered = new Set(
|
||||
[...peerIds].filter((peerId) => !this.#workersByPeer.has(peerId) && !this.#pendingPeer(peerId)),
|
||||
);
|
||||
if (undiscovered.size === 0) return;
|
||||
this.#discoveryPeers = undiscovered;
|
||||
const discovered = new Promise<void>((resolve) => {
|
||||
this.#resolveDiscovery = resolve;
|
||||
});
|
||||
await this.#coordinator.broadcast({ type: "discover_workers" });
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
await Promise.race([
|
||||
discovered,
|
||||
new Promise<void>((resolve) => {
|
||||
timer = setTimeout(resolve, WORKER_DISCOVERY_TIMEOUT_MS);
|
||||
timer.unref();
|
||||
}),
|
||||
]);
|
||||
if (timer) clearTimeout(timer);
|
||||
this.#discoveryPeers = undefined;
|
||||
this.#resolveDiscovery = undefined;
|
||||
}
|
||||
|
||||
async openSession(
|
||||
metadata: JsonlSessionMetadata,
|
||||
context: Context,
|
||||
pluginManifestPaths: readonly string[],
|
||||
): Promise<RoutedSessionHandle> {
|
||||
if (this.#detached || this.#shuttingDown) throw new Error("Experimental server is shutting down");
|
||||
this.assertSessionPluginManifestPaths(metadata, pluginManifestPaths);
|
||||
const existing = this.#workersBySession.get(metadata.path);
|
||||
if (existing) return this.#routedHandle(existing);
|
||||
const pending = this.#pending.get(metadata.path);
|
||||
if (pending) return this.#routedHandle(await pending.promise);
|
||||
return this.#routedHandle(await this.#launch(metadata, context, pluginManifestPaths));
|
||||
}
|
||||
|
||||
async closeSession(metadata: JsonlSessionMetadata, context: Context): Promise<void> {
|
||||
const worker = this.#workersBySession.get(metadata.path) ?? (await this.#pending.get(metadata.path)?.promise);
|
||||
if (worker !== undefined) await this.#stopWorker(worker, context);
|
||||
}
|
||||
|
||||
#routedHandle(worker: WorkerRecord): RoutedSessionHandle {
|
||||
return {
|
||||
terminated: worker.terminated,
|
||||
attachClient: (context) => this.#attachClient(worker, context),
|
||||
close: (context) => this.#stopWorker(worker, context),
|
||||
};
|
||||
}
|
||||
|
||||
async #attachClient(worker: WorkerRecord, context: Context): Promise<RoutedSessionAttachment> {
|
||||
if (this.#detached || this.#shuttingDown || worker.stopping) {
|
||||
throw new Error("Experimental Session worker is stopping");
|
||||
}
|
||||
if (this.#workersByPeer.get(worker.peerId) !== worker) {
|
||||
throw new Error("Experimental Session worker is no longer available");
|
||||
}
|
||||
const attachmentId = randomUUID();
|
||||
worker.attachmentIds.add(attachmentId);
|
||||
try {
|
||||
await this.#applyDemand(worker, attachmentId, true, true, context);
|
||||
} catch (error) {
|
||||
worker.attachmentIds.delete(attachmentId);
|
||||
throw error;
|
||||
}
|
||||
const scope = this.#operationScope(worker, attachmentId);
|
||||
let released = false;
|
||||
return {
|
||||
invokeService: (call, publish, serviceContext) =>
|
||||
this.#invokeService(worker, scope, call, publish, serviceContext),
|
||||
release: async (releaseContext) => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
if (this.#detached || !worker.attachmentIds.has(attachmentId)) return;
|
||||
try {
|
||||
await this.#applyDemand(worker, attachmentId, false, true, releaseContext);
|
||||
} catch (error) {
|
||||
if (!this.#detached && !this.#coordinator.wasReplaced) throw error;
|
||||
} finally {
|
||||
worker.attachmentIds.delete(attachmentId);
|
||||
this.#removeServiceSubscriptions((entry) => entry.worker === worker && sameScope(entry.scope, scope));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async #invokeService(
|
||||
worker: WorkerRecord,
|
||||
scope: WorkerOperationScope,
|
||||
call: ServiceCall,
|
||||
publish: (subscriptionId: string, update: ServiceProviderUpdate, context: Context) => void | Promise<void>,
|
||||
context: Context,
|
||||
): Promise<JsonValue | undefined> {
|
||||
const control = decodeServiceControlCall(call);
|
||||
let addedSubscriptionKey: string | undefined;
|
||||
if (control?.type === "subscribe") {
|
||||
const key = scopedServiceSubscriptionKey(scope, control.subscriptionId);
|
||||
if (this.#serviceSubscriptions.has(key)) throw new Error("Service subscription ID is already active");
|
||||
this.#serviceSubscriptions.set(key, {
|
||||
worker,
|
||||
scope,
|
||||
listener: (update, updateContext) => publish(control.subscriptionId, update, updateContext),
|
||||
deliveryTail: Promise.resolve(),
|
||||
});
|
||||
addedSubscriptionKey = key;
|
||||
}
|
||||
let response: JsonValue | undefined;
|
||||
try {
|
||||
response = await this.#invoke(worker, scope, call, context);
|
||||
} catch (error) {
|
||||
if (addedSubscriptionKey !== undefined && control?.type === "subscribe") {
|
||||
this.#serviceSubscriptions.delete(addedSubscriptionKey);
|
||||
void this.#invoke(
|
||||
worker,
|
||||
scope,
|
||||
createServiceUnsubscribeCall(control.subscriptionId),
|
||||
BACKGROUND_CONTEXT,
|
||||
).catch(() => {});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (control?.type === "unsubscribe") {
|
||||
this.#serviceSubscriptions.delete(scopedServiceSubscriptionKey(scope, control.subscriptionId));
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async #applyDemand(
|
||||
worker: WorkerRecord,
|
||||
attachmentId: string,
|
||||
attached: boolean,
|
||||
compensateOnTimeout: boolean,
|
||||
_context: Context,
|
||||
): Promise<void> {
|
||||
if (worker.stopping || this.#workersByPeer.get(worker.peerId) !== worker) {
|
||||
throw new Error("Experimental Session worker is stopping");
|
||||
}
|
||||
const requestId = randomUUID();
|
||||
let resolve!: () => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const applied = new Promise<void>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
if (compensateOnTimeout) void this.#reconcileDemandTimeout(requestId);
|
||||
else this.#rejectDemand(requestId, new Error("Session worker demand update timed out"));
|
||||
}, WORKER_DEMAND_TIMEOUT_MS);
|
||||
timer.unref();
|
||||
const pending = { attachmentId, attached, requestId, timer, worker, resolve, reject };
|
||||
this.#pendingDemand.set(requestId, pending);
|
||||
try {
|
||||
await this.#coordinator.send(worker.peerId, {
|
||||
type: "session_demand",
|
||||
serverConnectionId: this.#coordinator.serverConnectionId,
|
||||
requestId,
|
||||
attachmentId,
|
||||
attached,
|
||||
});
|
||||
} catch (error) {
|
||||
this.#rejectDemand(requestId, error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
|
||||
/** Worker operations have no wall-clock timeout: completion, disconnect, replacement, or shutdown settles them. */
|
||||
#invoke(
|
||||
worker: WorkerRecord,
|
||||
scope: WorkerOperationScope,
|
||||
call: ServiceCall,
|
||||
context: Context,
|
||||
): Promise<JsonValue | undefined> {
|
||||
try {
|
||||
this.#operationScope(worker, scope.attachmentId);
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
if (context.abortSignal?.aborted) return Promise.reject(abortError(context.abortSignal));
|
||||
const requestId = randomUUID();
|
||||
let resolve!: (result: JsonValue | undefined) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const result = new Promise<JsonValue | undefined>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
const signal = context.abortSignal;
|
||||
const onAbort = (): void => {
|
||||
if (signal === undefined || !this.#pendingOperations.has(requestId)) return;
|
||||
void this.#coordinator.send(worker.peerId, { type: "operation_cancel", requestId, scope }).catch(() => {});
|
||||
this.#rejectOperation(requestId, abortError(signal));
|
||||
};
|
||||
if (signal !== undefined) signal.addEventListener("abort", onAbort, { once: true });
|
||||
this.#pendingOperations.set(requestId, {
|
||||
worker,
|
||||
scope,
|
||||
cleanup: () => signal?.removeEventListener("abort", onAbort),
|
||||
resolve,
|
||||
reject,
|
||||
});
|
||||
void this.#coordinator
|
||||
.send(worker.peerId, { type: "operation", requestId, scope, call })
|
||||
.catch((error: unknown) =>
|
||||
this.#rejectOperation(requestId, error instanceof Error ? error : new Error(String(error))),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
#operationScope(worker: WorkerRecord, attachmentId: string): WorkerOperationScope {
|
||||
if (this.#detached || this.#shuttingDown || worker.stopping) {
|
||||
throw new Error("Experimental Session worker is stopping");
|
||||
}
|
||||
if (this.#workersByPeer.get(worker.peerId) !== worker || !worker.attachmentIds.has(attachmentId)) {
|
||||
throw new Error("Experimental Session worker has no active attachment");
|
||||
}
|
||||
return {
|
||||
serverConnectionId: this.#coordinator.serverConnectionId,
|
||||
attachmentId,
|
||||
};
|
||||
}
|
||||
|
||||
#stopWorker(worker: WorkerRecord, _context: Context): Promise<void> {
|
||||
if (this.#detached || this.#workersByPeer.get(worker.peerId) !== worker) return Promise.resolve();
|
||||
worker.stopPromise ??= this.#stopWorkerInternal(worker);
|
||||
return worker.stopPromise;
|
||||
}
|
||||
|
||||
async #stopWorkerInternal(worker: WorkerRecord): Promise<void> {
|
||||
this.#rejectWorkerOperations(worker, new Error("Session worker is stopping"));
|
||||
worker.stopping = true;
|
||||
worker.expectedStop = true;
|
||||
void this.#coordinator.send(worker.peerId, { type: "shutdown" }).catch(() => {});
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const timedOut = new Promise<boolean>((resolve) => {
|
||||
timer = setTimeout(() => resolve(true), WORKER_SHUTDOWN_TIMEOUT_MS);
|
||||
timer.unref();
|
||||
});
|
||||
try {
|
||||
if ((await Promise.race([worker.terminated.then(() => false), timedOut])) === true) {
|
||||
try {
|
||||
process.kill(worker.pid, "SIGKILL");
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error && "code" in error && error.code === "ESRCH")) throw error;
|
||||
}
|
||||
this.#removeWorker(worker, undefined);
|
||||
await worker.terminated;
|
||||
}
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
if (this.#detached || this.#shuttingDown) return;
|
||||
this.#shuttingDown = true;
|
||||
const pendingWorkers = [...this.#pending.values()];
|
||||
for (const pending of pendingWorkers) {
|
||||
void this.#coordinator.send(pending.peerId, { type: "shutdown" }).catch(() => {});
|
||||
}
|
||||
const pendingFinished = Promise.all(
|
||||
pendingWorkers.map((pending) => {
|
||||
if (pending.child.exitCode !== null || pending.child.signalCode !== null) return Promise.resolve();
|
||||
return new Promise<void>((resolve) => pending.child.once("exit", () => resolve()));
|
||||
}),
|
||||
).then(() => undefined);
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const pendingTimedOut = new Promise<boolean>((resolve) => {
|
||||
timer = setTimeout(() => resolve(true), WORKER_SHUTDOWN_TIMEOUT_MS);
|
||||
timer.unref();
|
||||
});
|
||||
const stopPending = (async () => {
|
||||
if ((await Promise.race([pendingFinished.then(() => false), pendingTimedOut])) === true) {
|
||||
for (const pending of pendingWorkers) pending.child.kill("SIGKILL");
|
||||
await pendingFinished;
|
||||
}
|
||||
if (timer) clearTimeout(timer);
|
||||
})();
|
||||
await Promise.all([
|
||||
stopPending,
|
||||
...[...this.#workersBySession.values()].map((worker) => this.#stopWorker(worker, BACKGROUND_CONTEXT)),
|
||||
]);
|
||||
this.#detachState();
|
||||
}
|
||||
|
||||
/** Forget workers without stopping them when this server is replaced. */
|
||||
detach(): void {
|
||||
if (this.#detached) return;
|
||||
this.#detached = true;
|
||||
for (const pending of this.#pending.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(new Error("Experimental server was replaced"));
|
||||
}
|
||||
for (const requestId of [...this.#pendingOperations.keys()]) {
|
||||
this.#rejectOperation(requestId, new Error("Experimental server was replaced during a worker operation"));
|
||||
}
|
||||
this.#detachState();
|
||||
}
|
||||
|
||||
#launch(
|
||||
metadata: JsonlSessionMetadata,
|
||||
_context: Context,
|
||||
pluginManifestPaths: readonly string[],
|
||||
): Promise<WorkerRecord> {
|
||||
const sessionKey = metadata.path;
|
||||
const peerId = `worker-${randomUUID()}`;
|
||||
const token = randomUUID();
|
||||
let child: ChildProcess;
|
||||
try {
|
||||
const options: SessionWorkerOptions = {
|
||||
sessionDir: this.#sessionDir,
|
||||
metadata: {
|
||||
id: metadata.id,
|
||||
createdAt: metadata.createdAt,
|
||||
storageVersion: metadata.storageVersion,
|
||||
cwd: metadata.cwd,
|
||||
path: metadata.path,
|
||||
modifiedAt: metadata.modifiedAt,
|
||||
...(metadata.parentSessionId === undefined ? {} : { parentSessionId: metadata.parentSessionId }),
|
||||
},
|
||||
pluginManifestPaths: [...pluginManifestPaths],
|
||||
...(this.#model ?? {}),
|
||||
};
|
||||
child = spawnInternalProcess("session-worker", [JSON.stringify(options)], {
|
||||
env: {
|
||||
[SESSION_WORKER_CONTROL_ADDRESS_ENV]: this.#coordinator.controlPath,
|
||||
[SESSION_WORKER_CONTROL_TOKEN_ENV]: token,
|
||||
[SESSION_WORKER_SESSION_KEY_ENV]: Buffer.from(sessionKey).toString("base64url"),
|
||||
[SESSION_WORKER_PEER_ID_ENV]: peerId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
let resolve!: (worker: WorkerRecord) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<WorkerRecord>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
const timer = setTimeout(
|
||||
() => this.#failPending(sessionKey, new Error("Session worker startup timed out")),
|
||||
WORKER_STARTUP_TIMEOUT_MS,
|
||||
);
|
||||
timer.unref();
|
||||
const pending = {
|
||||
sessionKey,
|
||||
peerId,
|
||||
token,
|
||||
pluginManifestPaths: Object.freeze([...pluginManifestPaths]),
|
||||
child,
|
||||
timer,
|
||||
promise,
|
||||
resolve,
|
||||
reject,
|
||||
};
|
||||
this.#pending.set(sessionKey, pending);
|
||||
this.#notifyWorkerCountChanged();
|
||||
child.once("error", (error) => this.#failPending(sessionKey, error));
|
||||
child.once("exit", (code, signal) => this.#childExited(pending, code, signal));
|
||||
return promise;
|
||||
}
|
||||
|
||||
#handleCoordinatorEvent(event: CoordinatorConnectionEvent): void {
|
||||
if (this.#detached) return;
|
||||
if (event.type === "peer_disconnected") {
|
||||
this.#markDiscovered(event.peerId);
|
||||
const worker = this.#workersByPeer.get(event.peerId);
|
||||
if (worker) {
|
||||
this.#removeWorker(
|
||||
worker,
|
||||
worker.expectedStop
|
||||
? undefined
|
||||
: new Error(`Session worker ${worker.metadata.id} disconnected unexpectedly`),
|
||||
);
|
||||
}
|
||||
const pending = this.#pendingPeer(event.peerId);
|
||||
if (pending) this.#failPending(pending.sessionKey, new Error("Session worker disconnected during startup"));
|
||||
return;
|
||||
}
|
||||
if (event.type !== "message") return;
|
||||
if (!Check(SessionWorkerEventSchema, event.payload)) {
|
||||
if (
|
||||
typeof event.payload === "object" &&
|
||||
event.payload !== null &&
|
||||
"type" in event.payload &&
|
||||
event.payload.type === "operation_response"
|
||||
) {
|
||||
const worker = this.#workersByPeer.get(event.from);
|
||||
if (worker)
|
||||
this.#rejectWorkerOperations(worker, new Error("Session worker returned an invalid operation response"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
const message: SessionWorkerEvent = event.payload;
|
||||
if (message.type === "worker_failed") {
|
||||
const pending = this.#pending.get(message.sessionKey);
|
||||
if (pending?.peerId === event.from && pending.token === message.token) {
|
||||
this.#failPending(message.sessionKey, new Error(`Session worker failed: ${message.message}`));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.type === "demand_applied" || message.type === "demand_rejected") {
|
||||
const pending = this.#pendingDemand.get(message.requestId);
|
||||
if (
|
||||
!pending ||
|
||||
pending.worker.peerId !== event.from ||
|
||||
pending.worker.token !== message.token ||
|
||||
pending.worker.metadata.path !== message.sessionKey ||
|
||||
(message.type === "demand_applied" &&
|
||||
(pending.attachmentId !== message.attachmentId || pending.attached !== message.attached))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.#pendingDemand.delete(message.requestId);
|
||||
clearTimeout(pending.timer);
|
||||
if (message.type === "demand_applied") pending.resolve();
|
||||
else pending.reject(new Error(`Session worker rejected demand: ${message.message}`));
|
||||
return;
|
||||
}
|
||||
if (message.type === "operation_response") {
|
||||
this.#handleOperationResponse(event.from, message.token, message.sessionKey, message.response);
|
||||
return;
|
||||
}
|
||||
if (message.type === "service_update") {
|
||||
this.#handleServiceEvent(
|
||||
event.from,
|
||||
message.token,
|
||||
message.sessionKey,
|
||||
message.scope,
|
||||
message.subscriptionId,
|
||||
message.update,
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.#recordReadyWorker(event.from, message);
|
||||
}
|
||||
|
||||
#handleOperationResponse(
|
||||
peerId: string,
|
||||
token: string,
|
||||
sessionKey: string,
|
||||
response: WorkerOperationResponse,
|
||||
): void {
|
||||
const pending = this.#pendingOperations.get(response.requestId);
|
||||
if (!pending) return;
|
||||
if (
|
||||
pending.worker.peerId !== peerId ||
|
||||
pending.worker.token !== token ||
|
||||
pending.worker.metadata.path !== sessionKey ||
|
||||
response.scope.serverConnectionId !== pending.scope.serverConnectionId ||
|
||||
response.scope.attachmentId !== pending.scope.attachmentId
|
||||
) {
|
||||
this.#rejectOperation(
|
||||
response.requestId,
|
||||
new Error("Session worker returned a mismatched operation response"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.#pendingOperations.delete(response.requestId);
|
||||
pending.cleanup();
|
||||
if (response.type === "operation_error") {
|
||||
pending.reject(
|
||||
response.code === undefined
|
||||
? new Error(`Session worker operation failed: ${response.message}`)
|
||||
: new ServerError(response.code, response.message),
|
||||
);
|
||||
} else {
|
||||
pending.resolve(response.result);
|
||||
}
|
||||
}
|
||||
|
||||
#handleServiceEvent(
|
||||
peerId: string,
|
||||
token: string,
|
||||
sessionKey: string,
|
||||
scope: WorkerOperationScope,
|
||||
subscriptionId: string,
|
||||
update: unknown,
|
||||
): void {
|
||||
const entry = this.#serviceSubscriptions.get(scopedServiceSubscriptionKey(scope, subscriptionId));
|
||||
if (
|
||||
entry === undefined ||
|
||||
entry.worker.peerId !== peerId ||
|
||||
entry.worker.token !== token ||
|
||||
entry.worker.metadata.path !== sessionKey ||
|
||||
!sameScope(entry.scope, scope)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let parsed: ServiceProviderUpdate;
|
||||
try {
|
||||
parsed = parseServiceProviderUpdate(update);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
entry.deliveryTail = entry.deliveryTail.then(() => entry.listener(parsed, TODO_CONTEXT)).catch(() => {});
|
||||
}
|
||||
|
||||
#recordReadyWorker(peerId: string, message: Extract<SessionWorkerEvent, { type: "worker_ready" }>): void {
|
||||
if (
|
||||
message.sessionKey !== message.metadata.path ||
|
||||
message.sessionId !== message.metadata.id ||
|
||||
!isAbsolute(message.metadata.cwd) ||
|
||||
!isAbsolute(message.metadata.path)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.#markDiscovered(peerId);
|
||||
const pending = this.#pending.get(message.sessionKey);
|
||||
if (pending?.peerId === peerId && !sameStrings(message.pluginManifestPaths, pending.pluginManifestPaths)) {
|
||||
this.#failPending(message.sessionKey, new Error("Session worker started with stale plugin packages"));
|
||||
return;
|
||||
}
|
||||
const existing = this.#workersBySession.get(message.sessionKey);
|
||||
if (existing && existing.peerId !== peerId) {
|
||||
void this.#coordinator.send(peerId, { type: "shutdown" }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
pending &&
|
||||
(pending.peerId !== peerId || pending.token !== message.token || pending.child.pid !== message.pid)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (existing) return;
|
||||
let resolveTerminated!: (error: Error | undefined) => void;
|
||||
const terminated = new Promise<Error | undefined>((resolve) => {
|
||||
resolveTerminated = resolve;
|
||||
});
|
||||
const worker: WorkerRecord = {
|
||||
peerId,
|
||||
metadata: message.metadata,
|
||||
pid: message.pid,
|
||||
token: message.token,
|
||||
pluginManifestPaths: Object.freeze([...message.pluginManifestPaths]),
|
||||
terminated,
|
||||
resolveTerminated,
|
||||
attachmentIds: new Set(),
|
||||
expectedStop: false,
|
||||
stopping: false,
|
||||
};
|
||||
this.#workersBySession.set(message.sessionKey, worker);
|
||||
this.#workersByPeer.set(peerId, worker);
|
||||
this.workerPids.set(message.sessionId, message.pid);
|
||||
if (pending) {
|
||||
this.#pending.delete(message.sessionKey);
|
||||
clearTimeout(pending.timer);
|
||||
pending.resolve(worker);
|
||||
}
|
||||
this.#notifyWorkerCountChanged();
|
||||
}
|
||||
|
||||
#childExited(pending: PendingLaunch, code: number | null, signal: NodeJS.Signals | null): void {
|
||||
if (this.#pending.get(pending.sessionKey) === pending) {
|
||||
this.#failPending(
|
||||
pending.sessionKey,
|
||||
new Error(`Session worker exited before readiness (${signal ?? code ?? "unknown"})`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const worker = this.#workersByPeer.get(pending.peerId);
|
||||
if (worker) {
|
||||
this.#removeWorker(
|
||||
worker,
|
||||
worker.expectedStop
|
||||
? undefined
|
||||
: new Error(`Session worker ${worker.metadata.id} exited unexpectedly (${signal ?? code ?? "unknown"})`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#failPending(sessionKey: string, error: Error): void {
|
||||
const pending = this.#pending.get(sessionKey);
|
||||
if (!pending) return;
|
||||
this.#pending.delete(sessionKey);
|
||||
clearTimeout(pending.timer);
|
||||
this.#notifyWorkerCountChanged();
|
||||
if (pending.child.exitCode === null && pending.child.signalCode === null) pending.child.kill("SIGKILL");
|
||||
pending.reject(error);
|
||||
}
|
||||
|
||||
#rejectDemand(requestId: string, error: Error): void {
|
||||
const pending = this.#pendingDemand.get(requestId);
|
||||
if (!pending) return;
|
||||
this.#pendingDemand.delete(requestId);
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(error);
|
||||
}
|
||||
|
||||
#rejectOperation(requestId: string, error: Error): void {
|
||||
const pending = this.#pendingOperations.get(requestId);
|
||||
if (!pending) return;
|
||||
this.#pendingOperations.delete(requestId);
|
||||
pending.cleanup();
|
||||
pending.reject(error);
|
||||
}
|
||||
|
||||
#rejectWorkerOperations(worker: WorkerRecord, error: Error): void {
|
||||
for (const [requestId, pending] of this.#pendingOperations) {
|
||||
if (pending.worker === worker) this.#rejectOperation(requestId, error);
|
||||
}
|
||||
}
|
||||
|
||||
async #reconcileDemandTimeout(requestId: string): Promise<void> {
|
||||
const pending = this.#pendingDemand.get(requestId);
|
||||
if (!pending) return;
|
||||
this.#pendingDemand.delete(requestId);
|
||||
clearTimeout(pending.timer);
|
||||
const timeoutError = new Error("Session worker demand update timed out");
|
||||
try {
|
||||
await this.#applyDemand(pending.worker, pending.attachmentId, false, false, BACKGROUND_CONTEXT);
|
||||
if (pending.attached) pending.reject(timeoutError);
|
||||
else pending.resolve();
|
||||
} catch (cleanupError) {
|
||||
try {
|
||||
await this.#stopWorker(pending.worker, BACKGROUND_CONTEXT);
|
||||
} catch (stopError) {
|
||||
pending.reject(
|
||||
new AggregateError(
|
||||
[timeoutError, cleanupError, stopError],
|
||||
"Session worker demand reconciliation and termination failed",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
pending.reject(
|
||||
new AggregateError(
|
||||
[timeoutError, cleanupError],
|
||||
"Session worker demand reconciliation failed; worker was terminated",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#removeWorker(worker: WorkerRecord, error: Error | undefined): void {
|
||||
if (this.#workersByPeer.get(worker.peerId) !== worker) return;
|
||||
this.#rejectWorkerOperations(worker, new Error("Session worker disconnected during an operation"));
|
||||
this.#removeServiceSubscriptions((entry) => entry.worker === worker);
|
||||
for (const pending of [...this.#pendingDemand.values()]) {
|
||||
if (pending.worker === worker) {
|
||||
this.#rejectDemand(pending.requestId, new Error("Session worker disconnected during demand update"));
|
||||
}
|
||||
}
|
||||
this.#workersByPeer.delete(worker.peerId);
|
||||
this.#workersBySession.delete(worker.metadata.path);
|
||||
if (this.workerPids.get(worker.metadata.id) === worker.pid) this.workerPids.delete(worker.metadata.id);
|
||||
worker.resolveTerminated(error);
|
||||
this.#notifyWorkerCountChanged();
|
||||
}
|
||||
|
||||
#removeServiceSubscriptions(matches: (entry: WorkerServiceSubscription) => boolean): void {
|
||||
for (const [key, entry] of this.#serviceSubscriptions) {
|
||||
if (matches(entry)) this.#serviceSubscriptions.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
#notifyWorkerCountChanged(): void {
|
||||
this.#onWorkerCountChanged?.(this.#workersBySession.size + this.#pending.size);
|
||||
}
|
||||
|
||||
#pendingPeer(peerId: string): PendingLaunch | undefined {
|
||||
return [...this.#pending.values()].find((pending) => pending.peerId === peerId);
|
||||
}
|
||||
|
||||
#markDiscovered(peerId: string): void {
|
||||
if (!this.#discoveryPeers?.delete(peerId) || this.#discoveryPeers.size !== 0) return;
|
||||
this.#resolveDiscovery?.();
|
||||
}
|
||||
|
||||
#detachState(): void {
|
||||
this.#removeListener();
|
||||
for (const requestId of [...this.#pendingOperations.keys()]) {
|
||||
this.#rejectOperation(requestId, new Error("Experimental server detached during a worker operation"));
|
||||
}
|
||||
for (const pending of [...this.#pendingDemand.values()]) {
|
||||
this.#pendingDemand.delete(pending.requestId);
|
||||
clearTimeout(pending.timer);
|
||||
pending.resolve();
|
||||
}
|
||||
this.#pending.clear();
|
||||
this.#serviceSubscriptions.clear();
|
||||
this.#workersByPeer.clear();
|
||||
this.#workersBySession.clear();
|
||||
this.workerPids.clear();
|
||||
this.#discoveryPeers = undefined;
|
||||
this.#resolveDiscovery?.();
|
||||
this.#resolveDiscovery = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function sameStrings(left: readonly string[], right: readonly string[]): boolean {
|
||||
return left.length === right.length && left.every((value, index) => value === right[index]);
|
||||
}
|
||||
|
||||
function sameScope(left: WorkerOperationScope, right: WorkerOperationScope): boolean {
|
||||
return left.serverConnectionId === right.serverConnectionId && left.attachmentId === right.attachmentId;
|
||||
}
|
||||
|
||||
function scopedServiceSubscriptionKey(scope: WorkerOperationScope, subscriptionId: string): string {
|
||||
return `${scope.serverConnectionId}\0${scope.attachmentId}\0${subscriptionId}`;
|
||||
}
|
||||
|
||||
function abortError(signal: AbortSignal): Error {
|
||||
const reason: unknown = signal.reason;
|
||||
return reason instanceof Error ? reason : new DOMException("The operation was aborted", "AbortError");
|
||||
}
|
||||
884
third_party/pi/packages/coding-agent/src/experimental/session-worker.ts
vendored
Normal file
884
third_party/pi/packages/coding-agent/src/experimental/session-worker.ts
vendored
Normal file
@@ -0,0 +1,884 @@
|
||||
import { createConnection, type Socket } from "node:net";
|
||||
import { isAbsolute } from "node:path";
|
||||
import {
|
||||
isJsonValue,
|
||||
type JsonValue,
|
||||
parseServiceProviderUpdate,
|
||||
REMOTE_SERVICE_ERROR_CODES,
|
||||
RemoteServiceError,
|
||||
type RemoteServiceErrorCode,
|
||||
type ServiceCall,
|
||||
type ServiceProviderUpdate,
|
||||
} from "@earendil-works/chord";
|
||||
import {
|
||||
AgentHarness,
|
||||
type AgentHarness as AgentHarnessInstance,
|
||||
type AgentLane,
|
||||
BACKGROUND_CONTEXT,
|
||||
createBashTool,
|
||||
createReadTool,
|
||||
createWriteTool,
|
||||
type JsonlSessionMetadata,
|
||||
JsonlSessionRepo,
|
||||
type Session,
|
||||
TODO_CONTEXT,
|
||||
withCancel,
|
||||
} from "@earendil-works/pi-agent-core";
|
||||
import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
|
||||
import lockfile from "proper-lockfile";
|
||||
import Type, { type Static } from "typebox";
|
||||
import { Check } from "typebox/value";
|
||||
import { findInitialModel, resolveCliModel } from "../core/model-resolver.ts";
|
||||
import { ModelRuntime } from "../core/model-runtime.ts";
|
||||
import { SettingsManager } from "../core/settings-manager.ts";
|
||||
import { COORDINATOR_PROTOCOL_VERSION } from "./coordinator.ts";
|
||||
import { createSessionPluginFacetLoader } from "./plugins/bundled.ts";
|
||||
import {
|
||||
consumeInternalProcessRole,
|
||||
encodeControlLine,
|
||||
isDirectInternalProcessEntry,
|
||||
MAX_CONTROL_LINE_BYTES,
|
||||
} from "./process.ts";
|
||||
import {
|
||||
createSessionWorkerServices,
|
||||
type SessionWorkerRuntime,
|
||||
type SessionWorkerServices,
|
||||
type WorkerServiceScope,
|
||||
} from "./services/worker.ts";
|
||||
|
||||
export type { SessionWorkerRuntime } from "./services/worker.ts";
|
||||
|
||||
const StrictObject = <const T extends Parameters<typeof Type.Object>[0]>(properties: T) =>
|
||||
Type.Object(properties, { additionalProperties: false });
|
||||
const OpaqueJsonValueSchema = Type.Unsafe<JsonValue>(Type.Unknown());
|
||||
const ServiceCallSchema = Type.Unsafe<ServiceCall>(
|
||||
StrictObject({
|
||||
serviceId: Type.String({ minLength: 1 }),
|
||||
instance: Type.Optional(
|
||||
StrictObject({ key: Type.String({ minLength: 1 }), generation: Type.Integer({ minimum: 1 }) }),
|
||||
),
|
||||
member: Type.String({ minLength: 1 }),
|
||||
args: Type.Array(Type.Unknown()),
|
||||
}),
|
||||
);
|
||||
const RemoteServiceErrorCodeSchema = Type.Unsafe<RemoteServiceErrorCode>(
|
||||
Type.String({ pattern: `^(?:${REMOTE_SERVICE_ERROR_CODES.join("|")})$` }),
|
||||
);
|
||||
|
||||
export const SESSION_WORKER_CONTROL_ADDRESS_ENV = "PI_SESSION_WORKER_CONTROL_ADDRESS";
|
||||
export const SESSION_WORKER_CONTROL_TOKEN_ENV = "PI_SESSION_WORKER_CONTROL_TOKEN";
|
||||
export const SESSION_WORKER_SESSION_KEY_ENV = "PI_SESSION_WORKER_SESSION_KEY_BASE64";
|
||||
export const SESSION_WORKER_PEER_ID_ENV = "PI_SESSION_WORKER_PEER_ID";
|
||||
|
||||
export const SessionWorkerMetadataSchema = StrictObject({
|
||||
id: Type.String({ minLength: 1 }),
|
||||
createdAt: Type.Integer(),
|
||||
storageVersion: Type.Integer(),
|
||||
cwd: Type.String(),
|
||||
path: Type.String(),
|
||||
modifiedAt: Type.Number(),
|
||||
parentSessionId: Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
export const SessionWorkerOptionsSchema = StrictObject({
|
||||
sessionDir: Type.String({ minLength: 1 }),
|
||||
metadata: SessionWorkerMetadataSchema,
|
||||
provider: Type.Optional(Type.String({ minLength: 1 })),
|
||||
model: Type.Optional(Type.String({ minLength: 1 })),
|
||||
pluginManifestPaths: Type.Array(Type.String({ minLength: 1 })),
|
||||
});
|
||||
export type SessionWorkerOptions = Static<typeof SessionWorkerOptionsSchema>;
|
||||
|
||||
export const WorkerOperationScopeSchema = StrictObject({
|
||||
serverConnectionId: Type.String(),
|
||||
attachmentId: Type.String(),
|
||||
});
|
||||
export type WorkerOperationScope = WorkerServiceScope;
|
||||
|
||||
export const WorkerOperationRequestSchema = StrictObject({
|
||||
type: Type.Literal("operation"),
|
||||
requestId: Type.String({ minLength: 1 }),
|
||||
scope: WorkerOperationScopeSchema,
|
||||
call: ServiceCallSchema,
|
||||
});
|
||||
export type WorkerOperationRequest = Static<typeof WorkerOperationRequestSchema>;
|
||||
|
||||
export const WorkerOperationResponseSchema = Type.Union([
|
||||
StrictObject({
|
||||
type: Type.Literal("operation_result"),
|
||||
requestId: Type.String({ minLength: 1 }),
|
||||
scope: WorkerOperationScopeSchema,
|
||||
result: Type.Optional(OpaqueJsonValueSchema),
|
||||
}),
|
||||
StrictObject({
|
||||
type: Type.Literal("operation_error"),
|
||||
requestId: Type.String({ minLength: 1 }),
|
||||
scope: WorkerOperationScopeSchema,
|
||||
code: Type.Optional(RemoteServiceErrorCodeSchema),
|
||||
message: Type.String(),
|
||||
}),
|
||||
]);
|
||||
export type WorkerOperationResponse = Static<typeof WorkerOperationResponseSchema>;
|
||||
|
||||
export const SessionWorkerCommandSchema = Type.Union([
|
||||
Type.Object({ type: Type.Literal("shutdown") }),
|
||||
Type.Object({ type: Type.Literal("discover_workers") }),
|
||||
Type.Object({
|
||||
type: Type.Literal("session_demand"),
|
||||
serverConnectionId: Type.String(),
|
||||
requestId: Type.String(),
|
||||
attachmentId: Type.String(),
|
||||
attached: Type.Boolean(),
|
||||
}),
|
||||
WorkerOperationRequestSchema,
|
||||
StrictObject({
|
||||
type: Type.Literal("operation_cancel"),
|
||||
requestId: Type.String({ minLength: 1 }),
|
||||
scope: WorkerOperationScopeSchema,
|
||||
}),
|
||||
]);
|
||||
export type SessionWorkerCommand = Static<typeof SessionWorkerCommandSchema>;
|
||||
|
||||
export const SessionWorkerEventSchema = Type.Union([
|
||||
Type.Object({
|
||||
type: Type.Literal("worker_ready"),
|
||||
token: Type.String(),
|
||||
sessionKey: Type.String(),
|
||||
sessionId: Type.String(),
|
||||
pid: Type.Integer({ minimum: 1 }),
|
||||
metadata: SessionWorkerMetadataSchema,
|
||||
pluginManifestPaths: Type.Array(Type.String({ minLength: 1 })),
|
||||
}),
|
||||
Type.Object({
|
||||
type: Type.Literal("worker_failed"),
|
||||
token: Type.String(),
|
||||
sessionKey: Type.String(),
|
||||
message: Type.String(),
|
||||
}),
|
||||
Type.Object({
|
||||
type: Type.Literal("demand_applied"),
|
||||
token: Type.String(),
|
||||
sessionKey: Type.String(),
|
||||
requestId: Type.String(),
|
||||
attachmentId: Type.String(),
|
||||
attached: Type.Boolean(),
|
||||
}),
|
||||
Type.Object({
|
||||
type: Type.Literal("demand_rejected"),
|
||||
token: Type.String(),
|
||||
sessionKey: Type.String(),
|
||||
requestId: Type.String(),
|
||||
message: Type.String(),
|
||||
}),
|
||||
Type.Object({
|
||||
type: Type.Literal("operation_response"),
|
||||
token: Type.String(),
|
||||
sessionKey: Type.String(),
|
||||
response: WorkerOperationResponseSchema,
|
||||
}),
|
||||
Type.Object({
|
||||
type: Type.Literal("service_update"),
|
||||
token: Type.String(),
|
||||
sessionKey: Type.String(),
|
||||
scope: WorkerOperationScopeSchema,
|
||||
subscriptionId: Type.String({ minLength: 1 }),
|
||||
update: Type.Unknown(),
|
||||
}),
|
||||
]);
|
||||
export type SessionWorkerEvent = Static<typeof SessionWorkerEventSchema>;
|
||||
|
||||
/** Worker-local reconciliation of server-generation demand and Harness activity. */
|
||||
export class WorkerLifecycle {
|
||||
readonly #initialDemandGraceMs: number;
|
||||
readonly #orphanDemandGraceMs: number;
|
||||
readonly #onRetire: () => void;
|
||||
readonly #demands = new Map<string, { serverConnectionId: string; attachmentId: string; timer?: NodeJS.Timeout }>();
|
||||
readonly #activeOperations = new Set<string>();
|
||||
#currentServerConnectionId: string | undefined;
|
||||
#initialTimer: NodeJS.Timeout | undefined;
|
||||
#demandInitialized: boolean;
|
||||
#retirementHolds = 0;
|
||||
#retiring = false;
|
||||
|
||||
constructor(options: {
|
||||
initialServerConnectionId?: string;
|
||||
initialDemandGraceMs: number;
|
||||
orphanDemandGraceMs: number;
|
||||
onRetire(): void;
|
||||
}) {
|
||||
this.#currentServerConnectionId = options.initialServerConnectionId;
|
||||
this.#initialDemandGraceMs = options.initialDemandGraceMs;
|
||||
this.#orphanDemandGraceMs = options.orphanDemandGraceMs;
|
||||
this.#onRetire = options.onRetire;
|
||||
this.#demandInitialized = false;
|
||||
this.#initialTimer = setTimeout(() => {
|
||||
this.#initialTimer = undefined;
|
||||
this.#demandInitialized = true;
|
||||
this.#reconcile();
|
||||
}, this.#initialDemandGraceMs);
|
||||
this.#initialTimer.unref();
|
||||
}
|
||||
|
||||
serverConnected(serverConnectionId: string): void {
|
||||
this.#currentServerConnectionId = serverConnectionId;
|
||||
for (const demand of this.#demands.values()) {
|
||||
if (demand.serverConnectionId !== serverConnectionId || !demand.timer) continue;
|
||||
clearTimeout(demand.timer);
|
||||
delete demand.timer;
|
||||
}
|
||||
}
|
||||
|
||||
serverDisconnected(serverConnectionId: string): void {
|
||||
if (this.#currentServerConnectionId === serverConnectionId) this.#currentServerConnectionId = undefined;
|
||||
for (const [key, demand] of this.#demands) {
|
||||
if (demand.serverConnectionId !== serverConnectionId || demand.timer) continue;
|
||||
demand.timer = setTimeout(() => {
|
||||
if (this.#demands.get(key) !== demand) return;
|
||||
this.#demands.delete(key);
|
||||
this.#reconcile();
|
||||
}, this.#orphanDemandGraceMs);
|
||||
demand.timer.unref();
|
||||
}
|
||||
}
|
||||
|
||||
beginRequest(serverConnectionId: string, attachmentId: string): () => void {
|
||||
if (this.#retiring) throw new Error("Session worker is retiring");
|
||||
if (serverConnectionId !== this.#currentServerConnectionId) {
|
||||
throw new Error("Session worker received a request from a stale server generation");
|
||||
}
|
||||
const demand = this.#demands.get(demandKey(serverConnectionId, attachmentId));
|
||||
if (!demand || demand.timer) {
|
||||
throw new Error("Session worker request does not match the active attachment");
|
||||
}
|
||||
return this.holdRetirement();
|
||||
}
|
||||
|
||||
holdRetirement(): () => void {
|
||||
this.#retirementHolds += 1;
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
this.#retirementHolds -= 1;
|
||||
this.#reconcile();
|
||||
};
|
||||
}
|
||||
|
||||
setDemand(serverConnectionId: string, attachmentId: string, attached: boolean): void {
|
||||
if (this.#retiring) throw new Error("Session worker is retiring");
|
||||
if (serverConnectionId !== this.#currentServerConnectionId) {
|
||||
throw new Error("Session worker received demand from a stale server generation");
|
||||
}
|
||||
this.#demandInitialized = true;
|
||||
if (this.#initialTimer) {
|
||||
clearTimeout(this.#initialTimer);
|
||||
this.#initialTimer = undefined;
|
||||
}
|
||||
const key = demandKey(serverConnectionId, attachmentId);
|
||||
const previous = this.#demands.get(key);
|
||||
if (previous?.timer) clearTimeout(previous.timer);
|
||||
if (attached) this.#demands.set(key, { serverConnectionId, attachmentId });
|
||||
else this.#demands.delete(key);
|
||||
this.#reconcile();
|
||||
}
|
||||
|
||||
operationStarted(kind: "run" | "compaction" | "navigation", lane: string, operationId: string): void {
|
||||
this.#activeOperations.add(`${kind}\0${lane}\0${operationId}`);
|
||||
}
|
||||
|
||||
operationStopped(kind: "run" | "compaction" | "navigation", lane: string, operationId: string): void {
|
||||
this.#activeOperations.delete(`${kind}\0${lane}\0${operationId}`);
|
||||
this.#reconcile();
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.#initialTimer) clearTimeout(this.#initialTimer);
|
||||
for (const demand of this.#demands.values()) {
|
||||
if (demand.timer) clearTimeout(demand.timer);
|
||||
}
|
||||
this.#demands.clear();
|
||||
}
|
||||
|
||||
#reconcile(): void {
|
||||
if (
|
||||
this.#retiring ||
|
||||
!this.#demandInitialized ||
|
||||
this.#retirementHolds !== 0 ||
|
||||
this.#activeOperations.size !== 0 ||
|
||||
this.#demands.size !== 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.#retiring = true;
|
||||
this.#onRetire();
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_INITIAL_DEMAND_GRACE_MS = 10_000;
|
||||
const DEFAULT_ORPHAN_DEMAND_GRACE_MS = 30_000;
|
||||
export const SESSION_WORKER_INITIAL_DEMAND_GRACE_ENV = "__PI_SESSION_WORKER_INITIAL_DEMAND_GRACE_MS";
|
||||
export const SESSION_WORKER_ORPHAN_DEMAND_GRACE_ENV = "__PI_SESSION_WORKER_ORPHAN_DEMAND_GRACE_MS";
|
||||
|
||||
const CoordinatorInputSchema = Type.Union([
|
||||
Type.Object({
|
||||
type: Type.Literal("peer_registered"),
|
||||
peerId: Type.String(),
|
||||
serverConnectionId: Type.Optional(Type.String()),
|
||||
}),
|
||||
Type.Object({ type: Type.Literal("server_connected"), serverConnectionId: Type.String() }),
|
||||
Type.Object({ type: Type.Literal("server_disconnected"), serverConnectionId: Type.String() }),
|
||||
Type.Object({ type: Type.Literal("message"), from: Type.Literal("server"), payload: Type.Unknown() }),
|
||||
]);
|
||||
type CoordinatorInput = Static<typeof CoordinatorInputSchema>;
|
||||
|
||||
interface WorkerControl {
|
||||
readonly initialServerConnectionId?: string;
|
||||
readonly messages: AsyncIterable<unknown>;
|
||||
readonly socket: Socket;
|
||||
send(event: SessionWorkerEvent): Promise<void>;
|
||||
}
|
||||
|
||||
let failureControl: WorkerControl | undefined;
|
||||
|
||||
async function connectControl(): Promise<WorkerControl> {
|
||||
const address = process.env[SESSION_WORKER_CONTROL_ADDRESS_ENV];
|
||||
const token = process.env[SESSION_WORKER_CONTROL_TOKEN_ENV];
|
||||
const encodedSessionKey = process.env[SESSION_WORKER_SESSION_KEY_ENV];
|
||||
if (!address || !token || !encodedSessionKey) throw new Error("Session worker requires a control address");
|
||||
const peerId = process.env[SESSION_WORKER_PEER_ID_ENV];
|
||||
if (!peerId) throw new Error("Session worker requires a peer ID");
|
||||
const socket = createConnection(address);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once("connect", resolve);
|
||||
socket.once("error", reject);
|
||||
});
|
||||
const messages = createJsonLineMessages(socket);
|
||||
await writeJsonLine(socket, { type: "register_peer", protocol: COORDINATOR_PROTOCOL_VERSION, peerId });
|
||||
const registered = await messages[Symbol.asyncIterator]().next();
|
||||
if (
|
||||
registered.done ||
|
||||
!Check(CoordinatorInputSchema, registered.value) ||
|
||||
registered.value.type !== "peer_registered"
|
||||
) {
|
||||
throw new Error("Coordinator rejected the session worker registration");
|
||||
}
|
||||
return {
|
||||
...(registered.value.serverConnectionId === undefined
|
||||
? {}
|
||||
: { initialServerConnectionId: registered.value.serverConnectionId }),
|
||||
messages,
|
||||
socket,
|
||||
send: (event) => writeJsonLine(socket, { type: "send", to: "server", payload: event }),
|
||||
};
|
||||
}
|
||||
|
||||
async function readCommands(
|
||||
control: WorkerControl,
|
||||
handlers: {
|
||||
onShutdown(): void;
|
||||
onDiscovery(): void;
|
||||
onDemand(command: Extract<SessionWorkerCommand, { type: "session_demand" }>): Promise<void>;
|
||||
onOperation(command: WorkerOperationRequest): void;
|
||||
onOperationCancel(command: Extract<SessionWorkerCommand, { type: "operation_cancel" }>): void;
|
||||
onServerConnected(serverConnectionId: string): void;
|
||||
onServerDisconnected(serverConnectionId: string): void;
|
||||
},
|
||||
): Promise<void> {
|
||||
for await (const value of control.messages) {
|
||||
if (!Check(CoordinatorInputSchema, value)) {
|
||||
control.socket.destroy(new Error("Coordinator sent an invalid worker message"));
|
||||
return;
|
||||
}
|
||||
const message: CoordinatorInput = value;
|
||||
if (message.type === "server_connected") {
|
||||
handlers.onServerConnected(message.serverConnectionId);
|
||||
continue;
|
||||
}
|
||||
if (message.type === "server_disconnected") {
|
||||
handlers.onServerDisconnected(message.serverConnectionId);
|
||||
continue;
|
||||
}
|
||||
if (message.type !== "message" || !Check(SessionWorkerCommandSchema, message.payload)) continue;
|
||||
const command: SessionWorkerCommand = message.payload;
|
||||
if (command.type === "shutdown") handlers.onShutdown();
|
||||
else if (command.type === "discover_workers") handlers.onDiscovery();
|
||||
else if (command.type === "session_demand") await handlers.onDemand(command);
|
||||
else if (command.type === "operation_cancel") handlers.onOperationCancel(command);
|
||||
else handlers.onOperation(command);
|
||||
}
|
||||
}
|
||||
|
||||
function createJsonLineMessages(socket: Socket): AsyncIterable<unknown> {
|
||||
const queued: unknown[] = [];
|
||||
const waiters: ((value: unknown) => void)[] = [];
|
||||
let buffered = "";
|
||||
socket.setEncoding("utf8");
|
||||
socket.on("data", (chunk: string) => {
|
||||
buffered += chunk;
|
||||
if (Buffer.byteLength(buffered) > MAX_CONTROL_LINE_BYTES) {
|
||||
socket.destroy(new Error("Session worker control message is too large"));
|
||||
return;
|
||||
}
|
||||
while (true) {
|
||||
const newline = buffered.indexOf("\n");
|
||||
if (newline === -1) return;
|
||||
const line = buffered.slice(0, newline);
|
||||
buffered = buffered.slice(newline + 1);
|
||||
try {
|
||||
const value: unknown = JSON.parse(line);
|
||||
const waiter = waiters.shift();
|
||||
if (waiter) waiter(value);
|
||||
else queued.push(value);
|
||||
} catch {
|
||||
socket.destroy(new Error("Session worker received invalid control JSON"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
return {
|
||||
[Symbol.asyncIterator]() {
|
||||
return {
|
||||
next: async () => {
|
||||
const value = queued.shift() ?? (await new Promise<unknown>((resolve) => waiters.push(resolve)));
|
||||
return { done: false as const, value };
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function writeJsonLine(socket: Socket, message: unknown): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
socket.write(encodeControlLine(message), (error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function toWorkerServiceUpdate(update: ServiceProviderUpdate): ServiceProviderUpdate {
|
||||
if (!isJsonValue(update)) throw new Error("Service produced a non-JSON update");
|
||||
return parseServiceProviderUpdate(update);
|
||||
}
|
||||
|
||||
function demandKey(serverConnectionId: string, attachmentId: string): string {
|
||||
return `${serverConnectionId}\0${attachmentId}`;
|
||||
}
|
||||
|
||||
function sameScope(left: WorkerOperationScope, right: WorkerOperationScope): boolean {
|
||||
return left.serverConnectionId === right.serverConnectionId && left.attachmentId === right.attachmentId;
|
||||
}
|
||||
|
||||
function lifecycleDelay(name: string, fallback: number): number {
|
||||
const value = process.env[name];
|
||||
if (value === undefined) return fallback;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 0) throw new Error(`${name} must be a non-negative safe integer`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function closeResources(resources: {
|
||||
harness?: AgentHarnessInstance;
|
||||
services?: SessionWorkerServices;
|
||||
session?: Session<JsonlSessionMetadata>;
|
||||
repo: JsonlSessionRepo;
|
||||
executionEnv: NodeExecutionEnv;
|
||||
releaseOwnership: () => Promise<void>;
|
||||
}): Promise<void> {
|
||||
const errors: unknown[] = [];
|
||||
try {
|
||||
await resources.services?.dispose();
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
try {
|
||||
if (resources.harness) await resources.harness.close(TODO_CONTEXT);
|
||||
else await resources.session?.close(TODO_CONTEXT);
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
try {
|
||||
await resources.repo.close(TODO_CONTEXT);
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
try {
|
||||
await resources.executionEnv.cleanup(TODO_CONTEXT);
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
try {
|
||||
await resources.releaseOwnership();
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
if (errors.length === 1) throw errors[0];
|
||||
if (errors.length > 1) throw new AggregateError(errors, "Session worker cleanup failed");
|
||||
}
|
||||
|
||||
export type CreateSessionWorkerHarness = (
|
||||
session: Session<JsonlSessionMetadata>,
|
||||
options: SessionWorkerOptions,
|
||||
executionEnv: NodeExecutionEnv,
|
||||
) => Promise<SessionWorkerRuntime>;
|
||||
|
||||
async function run(options: SessionWorkerOptions, createHarness: CreateSessionWorkerHarness): Promise<void> {
|
||||
const { sessionDir, metadata } = options;
|
||||
const sessionId = metadata.id;
|
||||
const control = await connectControl();
|
||||
const token = process.env[SESSION_WORKER_CONTROL_TOKEN_ENV]!;
|
||||
const sessionKey = Buffer.from(process.env[SESSION_WORKER_SESSION_KEY_ENV]!, "base64url").toString();
|
||||
failureControl = control;
|
||||
const pluginManifestPaths = options.pluginManifestPaths;
|
||||
const releaseOwnership = await lockfile.lock(metadata.path, {
|
||||
realpath: true,
|
||||
stale: 2_000,
|
||||
update: 1_000,
|
||||
retries: { retries: 320, factor: 1, minTimeout: 25, maxTimeout: 25, maxRetryTime: 8_000 },
|
||||
});
|
||||
const executionEnv = new NodeExecutionEnv({ cwd: metadata.cwd });
|
||||
const repo = new JsonlSessionRepo({ fileSystem: executionEnv, sessionsRoot: sessionDir });
|
||||
let session: Session<JsonlSessionMetadata> | undefined;
|
||||
let harness: AgentHarnessInstance | undefined;
|
||||
let lane: AgentLane | undefined;
|
||||
let services: SessionWorkerServices | undefined;
|
||||
try {
|
||||
session = await repo.open(metadata, TODO_CONTEXT);
|
||||
const runtime = await createHarness(session, options, executionEnv);
|
||||
harness = runtime.harness;
|
||||
lane = runtime.lane ?? (await harness.lane("main", TODO_CONTEXT));
|
||||
services = await createSessionWorkerServices({
|
||||
lane,
|
||||
modelRuntime: runtime.modelRuntime,
|
||||
settingsManager: runtime.settingsManager,
|
||||
facetLoader: runtime.facetLoader,
|
||||
publish: (scope, subscriptionId, update) =>
|
||||
control.send({
|
||||
type: "service_update",
|
||||
token,
|
||||
sessionKey,
|
||||
scope,
|
||||
subscriptionId,
|
||||
update: toWorkerServiceUpdate(update),
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
try {
|
||||
await closeResources({ harness, services, session, repo, executionEnv, releaseOwnership });
|
||||
} catch (cleanupError) {
|
||||
throw new AggregateError([error, cleanupError], "Session worker startup and cleanup failed");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const activeRequests = new Map<
|
||||
string,
|
||||
{ readonly scope: WorkerOperationScope; readonly cancel: (reason?: unknown) => void }
|
||||
>();
|
||||
let lifecycle: WorkerLifecycle | undefined;
|
||||
let removeLifecycleListeners: (() => void)[] = [];
|
||||
let closing: Promise<void> | undefined;
|
||||
const close = (): Promise<void> => {
|
||||
if (closing) return closing;
|
||||
lifecycle?.close();
|
||||
services.removeSubscriptions(() => true);
|
||||
for (const request of activeRequests.values()) request.cancel(new Error("Session worker is closing"));
|
||||
activeRequests.clear();
|
||||
for (const remove of removeLifecycleListeners) remove();
|
||||
removeLifecycleListeners = [];
|
||||
closing = closeResources({ harness, services, repo, executionEnv, releaseOwnership });
|
||||
return closing;
|
||||
};
|
||||
const closeAndExit = (): void => {
|
||||
void close().then(
|
||||
() => process.exit(0),
|
||||
(error: unknown) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
lifecycle = new WorkerLifecycle({
|
||||
initialServerConnectionId: control.initialServerConnectionId,
|
||||
initialDemandGraceMs: lifecycleDelay(SESSION_WORKER_INITIAL_DEMAND_GRACE_ENV, DEFAULT_INITIAL_DEMAND_GRACE_MS),
|
||||
orphanDemandGraceMs: lifecycleDelay(SESSION_WORKER_ORPHAN_DEMAND_GRACE_ENV, DEFAULT_ORPHAN_DEMAND_GRACE_MS),
|
||||
onRetire: closeAndExit,
|
||||
});
|
||||
removeLifecycleListeners = [
|
||||
harness.events.on("run_start", (event) => lifecycle?.operationStarted("run", event.lane, event.runId)),
|
||||
harness.events.on("run_resume", (event) => lifecycle?.operationStarted("run", event.lane, event.runId)),
|
||||
harness.events.on("run_suspend", (event) => lifecycle?.operationStopped("run", event.lane, event.runId)),
|
||||
harness.events.on("run_end", (event) => lifecycle?.operationStopped("run", event.lane, event.runId)),
|
||||
harness.events.on("compaction_start", (event) =>
|
||||
lifecycle?.operationStarted("compaction", event.lane, event.runId),
|
||||
),
|
||||
harness.events.on("compaction_end", (event) =>
|
||||
lifecycle?.operationStopped("compaction", event.lane, event.runId),
|
||||
),
|
||||
harness.events.on("navigation_start", (event) =>
|
||||
lifecycle?.operationStarted("navigation", event.lane, event.runId),
|
||||
),
|
||||
harness.events.on("navigation_end", (event) =>
|
||||
lifecycle?.operationStopped("navigation", event.lane, event.runId),
|
||||
),
|
||||
harness.events.on("fault", closeAndExit),
|
||||
];
|
||||
|
||||
const handleOperation = async (request: WorkerOperationRequest): Promise<void> => {
|
||||
let releaseRequest = (): void => {};
|
||||
const cancellable = withCancel(BACKGROUND_CONTEXT);
|
||||
try {
|
||||
releaseRequest = lifecycle!.beginRequest(request.scope.serverConnectionId, request.scope.attachmentId);
|
||||
activeRequests.set(request.requestId, { scope: request.scope, cancel: cancellable.cancel });
|
||||
const result = await services.invoke(request.call, request.scope, cancellable.context);
|
||||
if (result !== undefined && !isJsonValue(result)) throw new Error("Service produced a non-JSON result");
|
||||
await control.send({
|
||||
type: "operation_response",
|
||||
token,
|
||||
sessionKey,
|
||||
response: {
|
||||
type: "operation_result",
|
||||
requestId: request.requestId,
|
||||
scope: request.scope,
|
||||
...(result === undefined ? {} : { result }),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
let code: RemoteServiceErrorCode | undefined;
|
||||
if (error instanceof RemoteServiceError) {
|
||||
code = error.code;
|
||||
} else if (error instanceof Error && "code" in error) {
|
||||
const candidate = error.code;
|
||||
if (Check(RemoteServiceErrorCodeSchema, candidate)) code = candidate;
|
||||
}
|
||||
await control.send({
|
||||
type: "operation_response",
|
||||
token,
|
||||
sessionKey,
|
||||
response: {
|
||||
type: "operation_error",
|
||||
requestId: request.requestId,
|
||||
scope: request.scope,
|
||||
...(code === undefined ? {} : { code }),
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
if (activeRequests.get(request.requestId)?.cancel === cancellable.cancel) {
|
||||
activeRequests.delete(request.requestId);
|
||||
}
|
||||
releaseRequest();
|
||||
}
|
||||
};
|
||||
|
||||
let ready = false;
|
||||
const announce = (): void => {
|
||||
if (!ready) return;
|
||||
void control
|
||||
.send({
|
||||
type: "worker_ready",
|
||||
token,
|
||||
sessionKey,
|
||||
sessionId,
|
||||
pid: process.pid,
|
||||
metadata,
|
||||
pluginManifestPaths: [...pluginManifestPaths],
|
||||
})
|
||||
.catch(() => closeAndExit());
|
||||
};
|
||||
void readCommands(control, {
|
||||
onShutdown: closeAndExit,
|
||||
onDiscovery: announce,
|
||||
onDemand: async (command) => {
|
||||
const releaseRetirement = lifecycle?.holdRetirement() ?? (() => {});
|
||||
try {
|
||||
try {
|
||||
if (!command.attached) {
|
||||
const matches = (scope: WorkerOperationScope): boolean =>
|
||||
scope.serverConnectionId === command.serverConnectionId &&
|
||||
scope.attachmentId === command.attachmentId;
|
||||
services.removeSubscriptions(matches);
|
||||
}
|
||||
lifecycle?.setDemand(command.serverConnectionId, command.attachmentId, command.attached);
|
||||
} catch (error) {
|
||||
await control.send({
|
||||
type: "demand_rejected",
|
||||
token,
|
||||
sessionKey,
|
||||
requestId: command.requestId,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await control.send({
|
||||
type: "demand_applied",
|
||||
token,
|
||||
sessionKey,
|
||||
requestId: command.requestId,
|
||||
attachmentId: command.attachmentId,
|
||||
attached: command.attached,
|
||||
});
|
||||
} finally {
|
||||
releaseRetirement();
|
||||
}
|
||||
},
|
||||
onOperation: (request) => {
|
||||
void handleOperation(request).catch(() => closeAndExit());
|
||||
},
|
||||
onOperationCancel: (command) => {
|
||||
const active = activeRequests.get(command.requestId);
|
||||
if (active !== undefined && sameScope(active.scope, command.scope)) {
|
||||
active.cancel(new DOMException("Service operation cancelled", "AbortError"));
|
||||
}
|
||||
},
|
||||
onServerConnected: (serverConnectionId) => lifecycle?.serverConnected(serverConnectionId),
|
||||
onServerDisconnected: (serverConnectionId) => {
|
||||
const matches = (scope: WorkerOperationScope): boolean => scope.serverConnectionId === serverConnectionId;
|
||||
services.removeSubscriptions(matches);
|
||||
for (const request of activeRequests.values()) {
|
||||
if (matches(request.scope)) request.cancel(new Error("Server disconnected"));
|
||||
}
|
||||
lifecycle?.serverDisconnected(serverConnectionId);
|
||||
},
|
||||
}).catch(() => closeAndExit());
|
||||
control.socket.once("close", closeAndExit);
|
||||
control.socket.once("error", () => closeAndExit());
|
||||
process.once("SIGTERM", closeAndExit);
|
||||
process.once("SIGINT", closeAndExit);
|
||||
|
||||
try {
|
||||
ready = true;
|
||||
await control.send({
|
||||
type: "worker_ready",
|
||||
token,
|
||||
sessionKey,
|
||||
sessionId,
|
||||
pid: process.pid,
|
||||
metadata,
|
||||
pluginManifestPaths: [...pluginManifestPaths],
|
||||
});
|
||||
} catch (error) {
|
||||
try {
|
||||
await close();
|
||||
} catch (cleanupError) {
|
||||
throw new AggregateError([error, cleanupError], "Session worker readiness and cleanup failed");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runSessionWorkerWithHarness(
|
||||
args: readonly string[],
|
||||
createHarness: CreateSessionWorkerHarness,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (args.length !== 1) throw new Error("Session worker requires one options argument");
|
||||
let options: unknown;
|
||||
try {
|
||||
options = JSON.parse(args[0]!);
|
||||
} catch (error) {
|
||||
throw new Error("Session worker received invalid options", { cause: error });
|
||||
}
|
||||
if (
|
||||
!Check(SessionWorkerOptionsSchema, options) ||
|
||||
!isAbsolute(options.sessionDir) ||
|
||||
!isAbsolute(options.metadata.cwd) ||
|
||||
!isAbsolute(options.metadata.path) ||
|
||||
(options.provider !== undefined && options.model === undefined)
|
||||
) {
|
||||
throw new Error("Session worker received invalid options");
|
||||
}
|
||||
await run(options, createHarness);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const token = process.env[SESSION_WORKER_CONTROL_TOKEN_ENV];
|
||||
const encodedSessionKey = process.env[SESSION_WORKER_SESSION_KEY_ENV];
|
||||
if (token && encodedSessionKey) {
|
||||
const sessionKey = Buffer.from(encodedSessionKey, "base64url").toString();
|
||||
await failureControl?.send({ type: "worker_failed", token, sessionKey, message }).catch(() => {});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function createCodingAgentHarness(
|
||||
session: Session<JsonlSessionMetadata>,
|
||||
options: SessionWorkerOptions,
|
||||
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) {
|
||||
resolved = await findInitialModel({
|
||||
scopedModels: [],
|
||||
isContinuing: true,
|
||||
defaultProvider: settingsManager.getDefaultProvider(),
|
||||
defaultModelId: settingsManager.getDefaultModel(),
|
||||
defaultThinkingLevel: settingsManager.getDefaultThinkingLevel(),
|
||||
modelRuntime,
|
||||
});
|
||||
} else {
|
||||
resolved = resolveCliModel({
|
||||
cliProvider: options.provider,
|
||||
cliModel: options.model,
|
||||
modelRuntime,
|
||||
});
|
||||
if (resolved.error) throw new Error(`Session worker could not resolve model: ${resolved.error}`);
|
||||
}
|
||||
if (!resolved.model) throw new Error("Session worker could not resolve a model");
|
||||
const tools = [createReadTool(), createWriteTool(), createBashTool()];
|
||||
const activeToolNames = tools.map((tool) => tool.name);
|
||||
const harness = (
|
||||
await AgentHarness.create(
|
||||
{
|
||||
session,
|
||||
models: modelRuntime,
|
||||
model: resolved.model,
|
||||
thinkingLevel: resolved.thinkingLevel,
|
||||
tools,
|
||||
activeToolNames,
|
||||
toolContext: { env: executionEnv },
|
||||
resources: {},
|
||||
},
|
||||
TODO_CONTEXT,
|
||||
)
|
||||
).harness;
|
||||
try {
|
||||
const lane = await harness.lane("main", TODO_CONTEXT);
|
||||
const currentActiveToolNames = await lane.getActiveTools(TODO_CONTEXT);
|
||||
if (
|
||||
currentActiveToolNames.length !== activeToolNames.length ||
|
||||
currentActiveToolNames.some((name, index) => name !== activeToolNames[index])
|
||||
) {
|
||||
await lane.setActiveTools(activeToolNames, TODO_CONTEXT);
|
||||
}
|
||||
return {
|
||||
harness,
|
||||
lane,
|
||||
modelRuntime,
|
||||
settingsManager,
|
||||
facetLoader: createSessionPluginFacetLoader(options.pluginManifestPaths),
|
||||
};
|
||||
} catch (error) {
|
||||
try {
|
||||
await harness.close(TODO_CONTEXT);
|
||||
} catch (cleanupError) {
|
||||
throw new AggregateError([error, cleanupError], "Session worker model selection and cleanup failed");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function runSessionWorkerProcess(args: readonly string[]): Promise<void> {
|
||||
return runSessionWorkerWithHarness(args, createCodingAgentHarness);
|
||||
}
|
||||
|
||||
if (isDirectInternalProcessEntry(import.meta.url)) {
|
||||
const role = consumeInternalProcessRole();
|
||||
if (role !== "session-worker") {
|
||||
throw new Error("Session worker entrypoint requires an internal session-worker invocation");
|
||||
}
|
||||
void runSessionWorkerProcess(process.argv.slice(2)).catch(() => process.exit(1));
|
||||
}
|
||||
86
third_party/pi/packages/coding-agent/src/experimental/source-resolver.ts
vendored
Normal file
86
third_party/pi/packages/coding-agent/src/experimental/source-resolver.ts
vendored
Normal file
@@ -0,0 +1,86 @@
|
||||
import { existsSync, readFileSync, statSync } from "node:fs";
|
||||
import { registerHooks } from "node:module";
|
||||
import { extname, resolve, sep } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
// Node strips TypeScript natively, but it does not apply the workspace source
|
||||
// aliases from tsconfig.json. Internal source processes preload this resolver so
|
||||
// they cannot silently fall through to stale package dist files.
|
||||
|
||||
interface TsConfig {
|
||||
readonly compilerOptions?: {
|
||||
readonly paths?: Readonly<Record<string, readonly string[]>>;
|
||||
};
|
||||
}
|
||||
|
||||
interface SourceAlias {
|
||||
readonly pattern: string;
|
||||
readonly prefix: string;
|
||||
readonly suffix: string;
|
||||
readonly replacements: readonly string[];
|
||||
}
|
||||
|
||||
const repositoryRoot = fileURLToPath(new URL("../../../..", import.meta.url));
|
||||
const tsconfigPath = resolve(repositoryRoot, "tsconfig.json");
|
||||
const tsconfig = JSON.parse(readFileSync(tsconfigPath, "utf8")) as TsConfig;
|
||||
const paths = tsconfig.compilerOptions?.paths;
|
||||
if (!paths) throw new Error(`Source runtime requires compilerOptions.paths in ${tsconfigPath}`);
|
||||
|
||||
const aliases: SourceAlias[] = Object.entries(paths)
|
||||
.filter(([pattern]) => pattern.startsWith("@earendil-works/"))
|
||||
.map(([pattern, replacements]) => {
|
||||
const wildcard = pattern.indexOf("*");
|
||||
if (wildcard !== -1 && pattern.indexOf("*", wildcard + 1) !== -1) {
|
||||
throw new Error(`Source runtime does not support multiple wildcards in ${pattern}`);
|
||||
}
|
||||
return {
|
||||
pattern,
|
||||
prefix: wildcard === -1 ? pattern : pattern.slice(0, wildcard),
|
||||
suffix: wildcard === -1 ? "" : pattern.slice(wildcard + 1),
|
||||
replacements,
|
||||
};
|
||||
})
|
||||
.sort((left, right) => right.pattern.length - left.pattern.length);
|
||||
|
||||
registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
let matchedPattern: string | undefined;
|
||||
for (const alias of aliases) {
|
||||
const wildcard = matchAlias(alias, specifier);
|
||||
if (wildcard === undefined) continue;
|
||||
matchedPattern ??= alias.pattern;
|
||||
for (const replacement of alias.replacements) {
|
||||
const resolved = resolveSourcePath(replacement.replace("*", wildcard));
|
||||
if (resolved) return { url: pathToFileURL(resolved).href, shortCircuit: true };
|
||||
}
|
||||
}
|
||||
if (matchedPattern) {
|
||||
throw new Error(`Source runtime could not resolve ${specifier} through tsconfig path ${matchedPattern}`);
|
||||
}
|
||||
return nextResolve(specifier, context);
|
||||
},
|
||||
});
|
||||
|
||||
function matchAlias(alias: SourceAlias, specifier: string): string | undefined {
|
||||
if (!alias.pattern.includes("*")) return specifier === alias.pattern ? "" : undefined;
|
||||
if (!specifier.startsWith(alias.prefix) || !specifier.endsWith(alias.suffix)) return undefined;
|
||||
return specifier.slice(alias.prefix.length, specifier.length - alias.suffix.length);
|
||||
}
|
||||
|
||||
function resolveSourcePath(replacement: string): string | undefined {
|
||||
const basePath = resolve(repositoryRoot, replacement);
|
||||
const rootPrefix = repositoryRoot.endsWith(sep) ? repositoryRoot : `${repositoryRoot}${sep}`;
|
||||
if (!basePath.startsWith(rootPrefix)) return undefined;
|
||||
const extension = extname(basePath);
|
||||
const sourceExtension =
|
||||
extension === ".js" ? ".ts" : extension === ".mjs" ? ".mts" : extension === ".cjs" ? ".cts" : undefined;
|
||||
const candidates = sourceExtension
|
||||
? [basePath, `${basePath.slice(0, -extension.length)}${sourceExtension}`]
|
||||
: extension === ".ts" || extension === ".mts" || extension === ".cts" || extension === ".json"
|
||||
? [basePath]
|
||||
: [basePath, `${basePath}.ts`, resolve(basePath, "index.ts")];
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
Reference in New Issue
Block a user