diff --git a/package-lock.json b/package-lock.json index 1f0610718..98f077553 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/coding-agent/install-lock/package-lock.json b/packages/coding-agent/install-lock/package-lock.json index 8d256f1b2..cdd894e5b 100644 --- a/packages/coding-agent/install-lock/package-lock.json +++ b/packages/coding-agent/install-lock/package-lock.json @@ -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", diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index d2bc722bf..de194040d 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -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", diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 208a4197d..5478cf416 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -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", diff --git a/packages/coding-agent/src/cli/experimental/commands/client.ts b/packages/coding-agent/src/cli/experimental/commands/client.ts index d237d3f0a..e6867c271 100644 --- a/packages/coding-agent/src/cli/experimental/commands/client.ts +++ b/packages/coding-agent/src/cli/experimental/commands/client.ts @@ -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("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("c command: "client", ...(auth === undefined ? {} : { auth }), ...(connect === undefined ? {} : { connect }), + ...(sessionId === undefined ? {} : { sessionId }), }, }; }) diff --git a/packages/coding-agent/src/cli/experimental/runtime.ts b/packages/coding-agent/src/cli/experimental/runtime.ts new file mode 100644 index 000000000..a53bd551e --- /dev/null +++ b/packages/coding-agent/src/cli/experimental/runtime.ts @@ -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; +} + +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 { + 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 | 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 { + 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 }; +} diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 5ca03f3d8..2add05d69 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -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 { + await new Promise((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 { + 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 { + 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 { + 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()); } diff --git a/packages/coding-agent/test/experimental-cli-command.test.ts b/packages/coding-agent/test/experimental-cli-command.test.ts index 6219e24d1..ed925813f 100644 --- a/packages/coding-agent/test/experimental-cli-command.test.ts +++ b/packages/coding-agent/test/experimental-cli-command.test.ts @@ -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" }], diff --git a/packages/coding-agent/test/experimental-remote-runtime.test.ts b/packages/coding-agent/test/experimental-remote-runtime.test.ts new file mode 100644 index 000000000..1fcf85497 --- /dev/null +++ b/packages/coding-agent/test/experimental-remote-runtime.test.ts @@ -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(); +const directories = new Set(); + +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"); + }); +}); diff --git a/packages/coding-agent/tsconfig.build.json b/packages/coding-agent/tsconfig.build.json index 66886dda9..38fbfad4d 100644 --- a/packages/coding-agent/tsconfig.build.json +++ b/packages/coding-agent/tsconfig.build.json @@ -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"] diff --git a/packages/coding-agent/vitest.config.ts b/packages/coding-agent/vitest.config.ts index 3c49c145d..a4579539f 100644 --- a/packages/coding-agent/vitest.config.ts +++ b/packages/coding-agent/vitest.config.ts @@ -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 }, diff --git a/tsconfig.json b/tsconfig.json index ea7a514f3..cc2f9a85f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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"],