feat(coding-agent): add local list and attach demo

This commit is contained in:
Christian Klotz 2026-08-14 00:36:33 +03:00
parent 7479148479
commit e2888c4a93
No known key found for this signature in database
12 changed files with 399 additions and 1 deletions

3
package-lock.json generated
View file

@ -5500,6 +5500,9 @@
"dependencies": {
"@earendil-works/pi-agent-core": "^0.84.1",
"@earendil-works/pi-ai": "^0.84.1",
"@earendil-works/pi-client": "^0.84.1",
"@earendil-works/pi-protocol": "^0.84.1",
"@earendil-works/pi-server": "^0.84.1",
"@earendil-works/pi-tui": "^0.84.1",
"@silvia-odwyer/photon-node": "0.3.4",
"chalk": "5.6.2",

View file

@ -489,6 +489,17 @@
"node": ">=22.19.0"
}
},
"node_modules/@earendil-works/pi-client": {
"version": "0.84.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-client/-/pi-client-0.84.1.tgz",
"license": "MIT",
"dependencies": {
"@earendil-works/pi-protocol": "^0.84.1"
},
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/@earendil-works/pi-coding-agent": {
"version": "0.84.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.84.1.tgz",
@ -496,6 +507,9 @@
"dependencies": {
"@earendil-works/pi-agent-core": "^0.84.1",
"@earendil-works/pi-ai": "^0.84.1",
"@earendil-works/pi-client": "^0.84.1",
"@earendil-works/pi-protocol": "^0.84.1",
"@earendil-works/pi-server": "^0.84.1",
"@earendil-works/pi-tui": "^0.84.1",
"@silvia-odwyer/photon-node": "0.3.4",
"chalk": "5.6.2",
@ -524,6 +538,30 @@
"node": ">=22.19.0"
}
},
"node_modules/@earendil-works/pi-protocol": {
"version": "0.84.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-protocol/-/pi-protocol-0.84.1.tgz",
"license": "MIT",
"dependencies": {
"@earendil-works/pi-agent-core": "^0.84.1",
"typebox": "1.3.7"
},
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/@earendil-works/pi-server": {
"version": "0.84.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-server/-/pi-server-0.84.1.tgz",
"license": "MIT",
"dependencies": {
"@earendil-works/pi-agent-core": "^0.84.1",
"@earendil-works/pi-protocol": "^0.84.1"
},
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/@earendil-works/pi-telemetry": {
"version": "0.84.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.84.1.tgz",

View file

@ -11,6 +11,9 @@
"dependencies": {
"@earendil-works/pi-agent-core": "^0.84.1",
"@earendil-works/pi-ai": "^0.84.1",
"@earendil-works/pi-client": "^0.84.1",
"@earendil-works/pi-protocol": "^0.84.1",
"@earendil-works/pi-server": "^0.84.1",
"@earendil-works/pi-tui": "^0.84.1",
"@silvia-odwyer/photon-node": "0.3.4",
"chalk": "5.6.2",
@ -514,6 +517,41 @@
"node": ">=22.19.0"
}
},
"node_modules/@earendil-works/pi-client": {
"version": "0.84.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-client/-/pi-client-0.84.1.tgz",
"license": "MIT",
"dependencies": {
"@earendil-works/pi-protocol": "^0.84.1"
},
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/@earendil-works/pi-protocol": {
"version": "0.84.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-protocol/-/pi-protocol-0.84.1.tgz",
"license": "MIT",
"dependencies": {
"@earendil-works/pi-agent-core": "^0.84.1",
"typebox": "1.3.7"
},
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/@earendil-works/pi-server": {
"version": "0.84.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-server/-/pi-server-0.84.1.tgz",
"license": "MIT",
"dependencies": {
"@earendil-works/pi-agent-core": "^0.84.1",
"@earendil-works/pi-protocol": "^0.84.1"
},
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/@earendil-works/pi-telemetry": {
"version": "0.84.1",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.84.1.tgz",

View file

@ -41,6 +41,9 @@
"dependencies": {
"@earendil-works/pi-agent-core": "^0.84.1",
"@earendil-works/pi-ai": "^0.84.1",
"@earendil-works/pi-client": "^0.84.1",
"@earendil-works/pi-protocol": "^0.84.1",
"@earendil-works/pi-server": "^0.84.1",
"@earendil-works/pi-tui": "^0.84.1",
"@silvia-odwyer/photon-node": "0.3.4",
"chalk": "5.6.2",

View file

@ -1,5 +1,5 @@
import type { AuthInput } from "../auth.ts";
import { Command } from "../command.ts";
import { Command, stringOption } from "../command.ts";
import {
authTokenFileOption,
authTokenOption,
@ -14,6 +14,7 @@ export interface ClientCommand {
readonly command: "client";
readonly auth?: AuthInput;
readonly connect?: TransportAddress;
readonly sessionId?: string;
}
export interface ClientCommandContext {
@ -21,14 +22,17 @@ export interface ClientCommandContext {
}
const connectOption = transportOption("--connect");
const sessionIdOption = stringOption("--session-id");
export const clientCommand = new Command<ClientCommand, ClientCommandContext>("client")
.option(connectOption)
.option(sessionIdOption)
.option(authTokenOption)
.option(authTokenFileOption)
.build((input) => {
const { auth, errors: authErrors } = parseAuth(input);
const connect = input.value(connectOption);
const sessionId = input.value(sessionIdOption);
const { errors: optionErrors } = parseLegacyOptions(input);
const errors = [...authErrors, ...optionErrors, ...unsupportedLegacyOptions("client", input)];
if (errors.length > 0) return { ok: false, errors };
@ -38,6 +42,7 @@ export const clientCommand = new Command<ClientCommand, ClientCommandContext>("c
command: "client",
...(auth === undefined ? {} : { auth }),
...(connect === undefined ? {} : { connect }),
...(sessionId === undefined ? {} : { sessionId }),
},
};
})

View file

@ -0,0 +1,140 @@
import { basename } from "node:path";
import { MemorySessionRepo, type Session } from "@earendil-works/pi-agent-core";
import { PiClient } from "@earendil-works/pi-client";
import {
createUnixTransportFactory,
discoverUnixServices,
type UnixServiceRoute,
} from "@earendil-works/pi-client/unix";
import { isServiceId } from "@earendil-works/pi-protocol";
import { generateServiceId, type PiServer, type PiServerService } from "@earendil-works/pi-server";
import { createUnixServer, getUnixSocketPath } from "@earendil-works/pi-server/unix";
import type { ClientCommand } from "./commands/client.ts";
const DEMO_SESSION_IDS = ["demo-1", "demo-2"] as const;
export interface ExperimentalMemoryServer {
readonly serviceId: string;
readonly socketPath: string;
readonly server: PiServer;
close(): Promise<void>;
}
export type ExperimentalClientResult =
| {
readonly kind: "list";
readonly sessions: readonly { serviceId: string; sessionId: string }[];
}
| { readonly kind: "attached"; readonly serviceId: string; readonly sessionId: string };
export interface StartExperimentalMemoryServerOptions {
/** Directory for service-addressed Unix sockets. Defaults to ~/.pi/server. */
readonly directory?: string;
readonly path?: string;
}
export interface RunExperimentalClientOptions {
/** Directory searched when --connect is omitted. Defaults to ~/.pi/server. */
readonly directory?: string;
}
/** Start the temporary in-memory list-and-attach server composition. */
export async function startExperimentalMemoryServer(
options: StartExperimentalMemoryServerOptions = {},
): Promise<ExperimentalMemoryServer> {
const serviceId = generateServiceId();
const repo = new MemorySessionRepo();
for (const id of DEMO_SESSION_IDS) {
const session = await repo.create({ id });
await session.close();
}
// The list-and-attach protocol only needs ownership and close semantics. The
// complete AgentHarness implementation will replace this session owner when
// remote Harness methods are added.
const service: PiServerService = {
sessions: repo,
createHarness: (session: Session) => Promise.resolve({ close: () => session.close() }),
};
const socketPath = options.path ?? getUnixSocketPath(serviceId, options.directory);
const server = createUnixServer(service, { serviceId, path: socketPath });
try {
await server.start();
} catch (error) {
await repo.close();
throw error;
}
let closePromise: Promise<void> | undefined;
return {
serviceId,
socketPath,
server,
close() {
if (closePromise === undefined) closePromise = server.close().finally(() => repo.close());
return closePromise;
},
};
}
/** Discover local services, then list sessions or attach to one selected session. */
export async function runExperimentalClient(
command: ClientCommand,
options: RunExperimentalClientOptions = {},
): Promise<ExperimentalClientResult> {
if (command.auth !== undefined) throw new Error("Authentication is not supported by the local demo server");
const routes = command.connect ? [routeFromExplicitPath(command.connect.path)] : await discoverUnixServices(options);
const discovered: { route: UnixServiceRoute; sessionIds: string[] }[] = [];
for (const route of routes) {
const client = await PiClient.connect({
serviceId: route.serviceId,
transportFactory: createUnixTransportFactory({ path: route.path }),
});
try {
const sessions = await client.listSessions();
discovered.push({ route, sessionIds: sessions.map(({ id }) => id) });
} finally {
await client.dispose();
}
}
const sessionId = command.sessionId;
if (sessionId === undefined) {
return {
kind: "list",
sessions: discovered
.flatMap(({ route, sessionIds }) =>
sessionIds.map((sessionId) => ({ serviceId: route.serviceId, sessionId })),
)
.sort(
(left, right) =>
left.serviceId.localeCompare(right.serviceId) || left.sessionId.localeCompare(right.sessionId),
),
};
}
const matches = discovered.filter((candidate) => candidate.sessionIds.includes(sessionId));
if (matches.length === 0) throw new Error(`No discovered service contains session ${sessionId}`);
if (matches.length > 1) throw new Error(`Session ${sessionId} is available from more than one service`);
const route = matches[0]!.route;
const client = await PiClient.connect({
serviceId: route.serviceId,
transportFactory: createUnixTransportFactory({ path: route.path }),
});
try {
const attached = await client.attachSession(sessionId);
return { kind: "attached", serviceId: route.serviceId, sessionId: attached.sessionId };
} finally {
await client.dispose();
}
}
function routeFromExplicitPath(path: string): UnixServiceRoute {
const name = basename(path);
const serviceId = name.endsWith(".sock") ? name.slice(0, -".sock".length) : "";
if (!isServiceId(serviceId)) {
throw new Error("--connect path must end with <32-character-service-id>.sock");
}
return { serviceId, path };
}

View file

@ -26,6 +26,10 @@ import {
validateAuthCommandArgs,
} from "./cli/auth-command.ts";
import { resolveCredentialForPrint } from "./cli/credential-print.ts";
import { experimentalCli } from "./cli/experimental/cli.ts";
import type { ClientCommand } from "./cli/experimental/commands/client.ts";
import type { ServerCommand } from "./cli/experimental/commands/server.ts";
import { runExperimentalClient, startExperimentalMemoryServer } from "./cli/experimental/runtime.ts";
import { processFileArguments } from "./cli/file-processor.ts";
import { buildInitialMessage } from "./cli/initial-message.ts";
import { listModels } from "./cli/list-models.ts";
@ -41,6 +45,7 @@ import {
} from "./core/agent-session-services.ts";
import { formatNoModelsAvailableMessage } from "./core/auth-guidance.ts";
import { AuthStorage, ReadOnlyAuthStorage } from "./core/auth-storage.ts";
import { areExperimentalFeaturesEnabled } from "./core/experimental.ts";
import { exportFromFile } from "./core/export-html/index.ts";
import type { InlineExtension } from "./core/extensions/types.ts";
import { applyHttpProxySettings, configureHttpDispatcher } from "./core/http-dispatcher.ts";
@ -562,6 +567,61 @@ async function promptForMissingSessionCwd(
]);
}
async function waitForTermination(): Promise<void> {
await new Promise<void>((resolve) => {
const finish = (): void => {
process.off("SIGINT", finish);
process.off("SIGTERM", finish);
resolve();
};
process.once("SIGINT", finish);
process.once("SIGTERM", finish);
});
}
async function runExperimentalServerCommand(command: ServerCommand): Promise<void> {
if (command.auth !== undefined) throw new Error("Authentication is not supported by the local demo server");
if (command.listen !== undefined) throw new Error("The local demo server uses its service-addressed Unix socket");
const runtime = await startExperimentalMemoryServer();
console.log(`Service: ${runtime.serviceId}`);
console.log(`Socket: ${runtime.socketPath}`);
try {
await waitForTermination();
} finally {
await runtime.close();
}
}
async function runExperimentalClientCommand(command: ClientCommand): Promise<void> {
const result = await runExperimentalClient(command);
if (result.kind === "attached") {
console.log(`${result.serviceId}\t${result.sessionId}\tattached`);
return;
}
for (const session of result.sessions) console.log(`${session.serviceId}\t${session.sessionId}`);
}
async function runExperimentalCommand(args: string[]): Promise<boolean> {
if (!areExperimentalFeaturesEnabled()) return false;
try {
const result = await experimentalCli.execute(args, {
runPi: () => {},
runServer: runExperimentalServerCommand,
runClient: runExperimentalClientCommand,
});
if (!result.ok) {
for (const error of result.errors) console.error(chalk.red(`Error: ${error}`));
process.exitCode = 1;
return true;
}
return result.command.command !== "pi";
} catch (error) {
console.error(chalk.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
process.exitCode = 1;
return true;
}
}
export interface MainOptions {
extensionFactories?: InlineExtension[];
}
@ -579,6 +639,10 @@ export async function main(args: string[], options?: MainOptions) {
return;
}
if (await runExperimentalCommand(args)) {
return;
}
if (process.platform === "win32") {
cleanupWindowsSelfUpdateQuarantine(getPackageDir());
}

View file

@ -69,6 +69,16 @@ describe("experimental CLI commands", () => {
});
});
test("parses a client session ID", () => {
expect(experimentalCli.parse(["client", "--session-id", "demo-1"])).toEqual({
ok: true,
command: {
command: "client",
sessionId: "demo-1",
},
});
});
test.each([
[["--auth-token", "secret"], { type: "token", token: "secret" }],
[["--auth-token-file", "/tmp/token"], { type: "file", path: "/tmp/token" }],

View file

@ -0,0 +1,76 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import {
type ExperimentalMemoryServer,
runExperimentalClient,
startExperimentalMemoryServer,
} from "../src/cli/experimental/runtime.ts";
const servers = new Set<ExperimentalMemoryServer>();
const directories = new Set<string>();
async function makeServer(): Promise<{ directory: string; runtime: ExperimentalMemoryServer }> {
const directory = await mkdtemp(join(tmpdir(), "pes-"));
directories.add(directory);
const runtime = await startExperimentalMemoryServer({ directory });
servers.add(runtime);
return { directory, runtime };
}
afterEach(async () => {
await Promise.all([...servers].map((server) => server.close()));
servers.clear();
await Promise.all([...directories].map((directory) => rm(directory, { recursive: true, force: true })));
directories.clear();
});
describe("experimental memory server composition", () => {
test("discovers and lists seeded sessions without hosting either session", async () => {
const { directory, runtime } = await makeServer();
await expect(runExperimentalClient({ command: "client" }, { directory })).resolves.toEqual({
kind: "list",
sessions: [
{ serviceId: runtime.serviceId, sessionId: "demo-1" },
{ serviceId: runtime.serviceId, sessionId: "demo-2" },
],
});
expect(runtime.server.hostedSessions).toEqual([]);
});
test("attaches to a seeded session and reuses its hosted owner", async () => {
const { directory, runtime } = await makeServer();
await expect(runExperimentalClient({ command: "client", sessionId: "demo-1" }, { directory })).resolves.toEqual({
kind: "attached",
serviceId: runtime.serviceId,
sessionId: "demo-1",
});
expect(runtime.server.hostedSessions.map(({ sessionId }) => sessionId)).toEqual(["demo-1"]);
await runExperimentalClient({
command: "client",
sessionId: "demo-1",
connect: { transport: "unix", path: runtime.socketPath },
});
expect(runtime.server.hostedSessions.map(({ sessionId }) => sessionId)).toEqual(["demo-1"]);
});
test("reports missing and ambiguous session selections", async () => {
const sharedDirectory = await mkdtemp(join(tmpdir(), "ped-"));
directories.add(sharedDirectory);
const firstShared = await startExperimentalMemoryServer({ directory: sharedDirectory });
const secondShared = await startExperimentalMemoryServer({ directory: sharedDirectory });
servers.add(firstShared);
servers.add(secondShared);
await expect(
runExperimentalClient({ command: "client", sessionId: "missing" }, { directory: sharedDirectory }),
).rejects.toThrow("No discovered service contains session missing");
await expect(
runExperimentalClient({ command: "client", sessionId: "demo-1" }, { directory: sharedDirectory }),
).rejects.toThrow("Session demo-1 is available from more than one service");
});
});

View file

@ -8,6 +8,11 @@
"@earendil-works/pi-ai": ["../ai/dist/index.d.ts"],
"@earendil-works/pi-ai/*": ["../ai/dist/*.d.ts", "../ai/dist/providers/*.d.ts"],
"@earendil-works/pi-client": ["../client/dist/index.d.ts"],
"@earendil-works/pi-client/unix": ["../client/dist/unix.d.ts"],
"@earendil-works/pi-client/*": ["../client/dist/*.d.ts"],
"@earendil-works/pi-server": ["../server/dist/index.d.ts"],
"@earendil-works/pi-server/unix": ["../server/dist/transports/unix/index.d.ts"],
"@earendil-works/pi-server/*": ["../server/dist/*.d.ts", "../server/dist/transports/*/index.d.ts"],
"@earendil-works/pi-protocol": ["../protocol/dist/index.d.ts"],
"@earendil-works/pi-tui": ["../tui/dist/index.d.ts"],
"@earendil-works/pi-tui/*": ["../tui/dist/*.d.ts", "../tui/dist/components/*.d.ts"]

View file

@ -22,6 +22,18 @@ export default mergeConfig(
},
resolve: {
alias: [
{
find: /^@earendil-works\/pi-client\/unix$/,
replacement: fileURLToPath(new URL("../client/src/unix.ts", import.meta.url)),
},
{
find: /^@earendil-works\/pi-server\/unix$/,
replacement: fileURLToPath(new URL("../server/src/transports/unix/index.ts", import.meta.url)),
},
{
find: /^@earendil-works\/pi-server$/,
replacement: fileURLToPath(new URL("../server/src/index.ts", import.meta.url)),
},
{
find: /^@earendil-works\/pi-client$/,
replacement: fileURLToPath(new URL("../client/src/index.ts", import.meta.url)),
@ -30,6 +42,8 @@ export default mergeConfig(
find: /^@earendil-works\/pi-protocol$/,
replacement: fileURLToPath(new URL("../protocol/src/index.ts", import.meta.url)),
},
{ find: /^@earendil-works\/pi-ai$/, replacement: workspaceSourcePaths.aiIndex },
{ find: /^@earendil-works\/pi-agent-core$/, replacement: workspaceSourcePaths.agentIndex },
{ find: /^@mariozechner\/pi-ai$/, replacement: workspaceSourcePaths.aiIndex },
{ find: /^@mariozechner\/pi-ai\/oauth$/, replacement: workspaceSourcePaths.aiOAuth },
{ find: /^@mariozechner\/pi-agent-core$/, replacement: workspaceSourcePaths.agentIndex },

View file

@ -25,8 +25,10 @@
"@earendil-works/pi-protocol": ["./packages/protocol/src/index.ts"],
"@earendil-works/pi-protocol/*": ["./packages/protocol/src/*"],
"@earendil-works/pi-client": ["./packages/client/src/index.ts"],
"@earendil-works/pi-client/unix": ["./packages/client/src/unix.ts"],
"@earendil-works/pi-client/*": ["./packages/client/src/*"],
"@earendil-works/pi-server": ["./packages/server/src/index.ts"],
"@earendil-works/pi-server/unix": ["./packages/server/src/transports/unix/index.ts"],
"@earendil-works/pi-server/*": ["./packages/server/src/*"],
"typebox": ["./node_modules/typebox"],
"@earendil-works/pi-tui": ["./packages/tui/src/index.ts"],