This commit is contained in:
Mario Zechner 2026-08-15 20:38:16 +02:00
commit d87b2b5c21
53 changed files with 2698 additions and 944 deletions

View file

@ -27,18 +27,7 @@ The client verifies that the physical endpoint reports the expected logical `ser
`attachSession()` currently returns only `{ sessionId }`. Remote Session and Harness methods will be added directly from the new shared interfaces in a later slice. The client does not reconnect or replay requests automatically. After disconnection, call `reconnect()` and explicitly repeat safe control-plane actions.
Server lifecycle control is not part of `PiClient`. Launchers use the separate `@earendil-works/pi-client/control` entry point to request a drain and wait for that server generation to close.
```ts
import { requestServerDrain } from "@earendil-works/pi-client/control";
await requestServerDrain({ serverId, transportFactory });
// Drain was acknowledged. Verifying endpoint release and starting a successor are launcher policy.
```
`requestServerDrain()` applies a 15-second total timeout across connection, handshake, and drain acknowledgement. Pass `timeoutMs` to override it. A timeout is ambiguous and is never replayed automatically. The transport-neutral helper does not prove process termination or endpoint release; a Unix launcher separately waits for its stable socket path to disappear.
The control API is administrative, not an authorization boundary. Only expose it over a transport that authenticates launchers as administrators. The experimental local launcher relies on a mode-`0600` Unix socket and treats every same-user peer as trusted.
The experimental local coordinator only provides a stable endpoint and relays traffic. Replaceable server processes own session and worker lifecycle outside the public client protocol.
Call transport handlers as follows:

View file

@ -10,10 +10,6 @@
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./control": {
"types": "./dist/control.d.ts",
"import": "./dist/control.js"
},
"./unix": {
"types": "./dist/unix.d.ts",
"import": "./dist/unix.js"

View file

@ -1,125 +0,0 @@
import {
createRpcClient,
encodeClientMessage,
isServerId,
ProtocolValidationError,
ServerControlRpc,
type ServerControlRpcCall,
} from "@earendil-works/pi-protocol";
import { Connection } from "./connection.ts";
import { PiDisconnectedError, PiServerError, toError } from "./errors.ts";
import { createPromiseResolvers } from "./promise.ts";
import type { ByteTransportFactory } from "./transport.ts";
const DEFAULT_SERVER_CONTROL_TIMEOUT_MS = 15_000;
const MAX_TIMER_DELAY_MS = 2_147_483_647;
export interface ServerControlOptions {
readonly transportFactory: ByteTransportFactory;
/** Logical server identity expected at the physical endpoint. */
readonly serverId: string;
readonly maxFrameLength?: number;
/** Total time allowed for handshake and drain acknowledgement. Defaults to 15 seconds. */
readonly timeoutMs?: number;
}
export class ServerControlTimeoutError extends Error {
readonly timeoutMs: number;
constructor(timeoutMs: number) {
super(`Server control timed out after ${timeoutMs} ms`);
this.name = "ServerControlTimeoutError";
this.timeoutMs = timeoutMs;
}
}
interface PendingControlRequest {
readonly id: string;
resolve(result: unknown): void;
reject(error: Error): void;
}
/**
* Ask one server generation to drain and wait for its acknowledgement.
* Physical endpoint shutdown is transport-specific launcher policy.
*/
export async function requestServerDrain(options: ServerControlOptions): Promise<void> {
if (!isServerId(options.serverId)) {
throw new TypeError("Server control serverId must be a canonical lowercase UUIDv4");
}
const timeoutMs = options.timeoutMs ?? DEFAULT_SERVER_CONTROL_TIMEOUT_MS;
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_TIMER_DELAY_MS) {
throw new TypeError(`Server control timeoutMs must be an integer between 1 and ${MAX_TIMER_DELAY_MS}`);
}
let pending: PendingControlRequest | undefined;
const connection = new Connection({
transportFactory: options.transportFactory,
serverId: options.serverId,
maxFrameLength: options.maxFrameLength,
onHandshake: () => {},
onMessage: (message) => {
if (!pending || message.id !== pending.id) {
connection.fail(new ProtocolValidationError("Control response has no matching request"));
return;
}
const request = pending;
pending = undefined;
if (message.ok) request.resolve(message.result);
else request.reject(new PiServerError(message.error));
},
onStateChange: (change) => {
if (change.state !== "disconnected") return;
const request = pending;
pending = undefined;
request?.reject(change.error ?? new PiDisconnectedError());
},
});
const control = createRpcClient(
ServerControlRpc,
(call) =>
sendControlRequest(connection, options.serverId, call, (request) => {
pending = request;
}),
(message) => new ProtocolValidationError(message),
);
const timeout = setTimeout(() => {
connection.disconnect(new ServerControlTimeoutError(timeoutMs));
}, timeoutMs);
timeout.unref();
try {
await connection.connect();
await control.drain();
} finally {
clearTimeout(timeout);
const request = pending;
pending = undefined;
request?.reject(new PiDisconnectedError("Server control client closed"));
connection.disconnect("Server control client closed");
}
}
function sendControlRequest(
connection: Connection,
serverId: string,
call: ServerControlRpcCall,
setPending: (request: PendingControlRequest) => void,
): Promise<unknown> {
const id = "control-1";
const { promise, resolve, reject } = createPromiseResolvers<unknown>();
setPending({ id, resolve, reject });
let frame: Uint8Array;
try {
frame = encodeClientMessage(
{ type: "request", id, serverId, call },
{ maxFrameLength: connection.maxFrameLength },
);
} catch (error) {
reject(toError(error));
return promise;
}
connection.send(frame);
return promise;
}

View file

@ -1,63 +0,0 @@
import { expect, test, vi } from "vitest";
import { requestServerDrain, ServerControlTimeoutError } from "../src/control.ts";
import { MemoryByteServer } from "./support.ts";
test("launcher control resolves after the drain acknowledgement", async () => {
const server = new MemoryByteServer();
const draining = requestServerDrain({
serverId: "00000000-0000-4000-8000-000000000001",
transportFactory: (handlers) => server.connect(handlers),
});
await server.waitForMessages(2);
expect(server.messages[1]).toEqual({
type: "request",
id: "control-1",
serverId: "00000000-0000-4000-8000-000000000001",
call: { method: "drain", args: [] },
});
server.send({
type: "response",
id: "control-1",
ok: true,
result: {},
});
await expect(draining).resolves.toBeUndefined();
expect(server.clientCloseCount).toBe(1);
});
test("fails a control request that does not complete within its total timeout", async () => {
vi.useFakeTimers();
try {
const server = new MemoryByteServer();
const draining = requestServerDrain({
serverId: "00000000-0000-4000-8000-000000000001",
transportFactory: (handlers) => server.connect(handlers),
timeoutMs: 25,
});
await server.waitForMessages(2);
const observed = draining.then(
() => undefined,
(error: unknown) => error,
);
await vi.advanceTimersByTimeAsync(25);
const thrown = await observed;
expect(thrown).toBeInstanceOf(ServerControlTimeoutError);
expect(thrown).toMatchObject({ timeoutMs: 25 });
expect(server.clientCloseCount).toBe(1);
} finally {
vi.useRealTimers();
}
});
test("validates the control timeout", async () => {
const server = new MemoryByteServer();
await expect(
requestServerDrain({
serverId: "00000000-0000-4000-8000-000000000001",
transportFactory: (handlers) => server.connect(handlers),
timeoutMs: 0,
}),
).rejects.toThrow(/timeoutMs/);
});

View file

@ -32,7 +32,7 @@ async function startServer(
const path = join(directory, `${fileServerId}.sock`);
const server = new PiServer(
{
sessions: { list: async () => [], open: async () => Promise.reject(new Error("unused")) },
sessions: { list: async () => [] },
createHarness: async () => Promise.reject(new Error("unused")),
},
{ listeners: [createUnixListener({ path })], serverId: reportedServerId },

View file

@ -672,6 +672,8 @@ pi --thinking high "Solve this complex problem"
| `PI_CODING_AGENT_DIR` | Override config directory (default: `~/.pi/agent`) |
| `PI_CODING_AGENT_SESSION_DIR` | Override session storage directory (overridden by `--session-dir`) |
| `PI_PACKAGE_DIR` | Override package directory (useful for Nix/Guix where store paths tokenize poorly) |
| `PI_SERVER_DIR` | Override experimental server profile and socket directory (default: `~/.pi/server`) |
| `PI_SERVER_ID` | Select the logical experimental server ID when `--server-id` is omitted |
| `PI_OFFLINE` | Disable startup network operations, including update checks, package update checks, and install/update telemetry |
| `PI_SKIP_VERSION_CHECK` | Skip the Pi version update check at startup. This prevents the `pi.dev` latest-version request |
| `PI_TELEMETRY` | Override install/update telemetry and provider attribution headers. Use `1`/`true`/`yes` to enable or `0`/`false`/`no` to disable. This does not disable update checks |

View file

@ -81,6 +81,8 @@ These variables are read by Pi itself:
| `PI_CODING_AGENT_DIR` | Override the config directory; default is `~/.pi/agent` |
| `PI_CODING_AGENT_SESSION_DIR` | Override session storage; overridden by `--session-dir` |
| `PI_PACKAGE_DIR` | Override the package directory, useful for Nix/Guix store paths |
| `PI_SERVER_DIR` | Override the experimental server profile and socket directory; default is `~/.pi/server` |
| `PI_SERVER_ID` | Select the logical experimental server ID when `--server-id` is omitted |
| `PI_OFFLINE` | Disable startup network operations, including update checks, package updates, and install/update telemetry |
| `PI_SKIP_VERSION_CHECK` | Disable the `pi.dev` latest-version request |
| `PI_TELEMETRY` | Override install/update telemetry and provider attribution headers: `1`/`true`/`yes` or `0`/`false`/`no` |

View file

@ -1,15 +1,14 @@
#!/usr/bin/env node
import { registerBunOAuthFlows } from "@earendil-works/pi-ai/bun-oauth";
import { APP_NAME } from "../config.ts";
process.title = APP_NAME;
process.emitWarning = (() => {}) as typeof process.emitWarning;
registerBunOAuthFlows();
import { runCoordinatorProcess } from "../cli/experimental/coordinator-process.ts";
import { consumeInternalProcessRole, getInternalProcessRole } from "../cli/experimental/internal-process.ts";
import { restoreSandboxEnv } from "./restore-sandbox-env.ts";
restoreSandboxEnv();
await import("./register-bedrock.ts");
await import("../cli.ts");
if (getInternalProcessRole() === "coordinator") {
consumeInternalProcessRole();
await runCoordinatorProcess(process.argv.slice(2));
} else {
await import("./runtime-setup.ts");
await import("../cli.ts");
}

View file

@ -1,4 +0,0 @@
import { bedrockProviderModule } from "@earendil-works/pi-ai/bedrock-provider";
import { setBedrockProviderModule } from "@earendil-works/pi-ai/compat";
setBedrockProviderModule(bedrockProviderModule);

View file

@ -0,0 +1,9 @@
import { bedrockProviderModule } from "@earendil-works/pi-ai/bedrock-provider";
import { registerBunOAuthFlows } from "@earendil-works/pi-ai/bun-oauth";
import { setBedrockProviderModule } from "@earendil-works/pi-ai/compat";
import { APP_NAME } from "../config.ts";
process.title = APP_NAME;
process.emitWarning = (() => {}) as typeof process.emitWarning;
registerBunOAuthFlows();
setBedrockProviderModule(bedrockProviderModule);

View file

@ -1,21 +1,25 @@
#!/usr/bin/env node
/**
* CLI entry point for the refactored coding agent.
* Uses main.ts with AgentSession and new mode modules.
*
* Test with: npx tsx src/cli-new.ts [args...]
*/
import { consumeInternalProcessRole } from "./cli/experimental/internal-process.ts";
import { runSessionWorkerProcess } from "./cli/experimental/session-worker-process.ts";
import { APP_NAME } from "./config.ts";
import { configureHttpDispatcher } from "./core/http-dispatcher.ts";
import { main } from "./main.ts";
process.title = APP_NAME;
process.env.PI_CODING_AGENT = "true";
process.env.AI_AGENT = "pi";
process.emitWarning = (() => {}) as typeof process.emitWarning;
const internalProcessRole = consumeInternalProcessRole();
if (internalProcessRole === "session-worker") {
void runSessionWorkerProcess(process.argv.slice(2)).catch(() => process.exit(1));
} else {
if (internalProcessRole !== undefined) {
throw new Error(`Internal ${internalProcessRole} process must use its lightweight entrypoint`);
}
process.title = APP_NAME;
process.env.PI_CODING_AGENT = "true";
process.env.AI_AGENT = "pi";
process.emitWarning = (() => {}) as typeof process.emitWarning;
// Configure undici's global dispatcher before provider SDKs issue requests.
// Runtime settings are applied once SettingsManager has loaded global/project settings.
configureHttpDispatcher();
// Configure undici's global dispatcher before provider SDKs issue requests.
// Runtime settings are applied once SettingsManager has loaded global/project settings.
configureHttpDispatcher();
main(process.argv.slice(2));
main(process.argv.slice(2));
}

View file

@ -412,6 +412,8 @@ ${chalk.bold("Environment Variables:")}
${ENV_AGENT_DIR.padEnd(32)} - Config directory (default: ~/${CONFIG_DIR_NAME}/agent)
${ENV_SESSION_DIR.padEnd(32)} - Session storage directory (overridden by --session-dir)
PI_PACKAGE_DIR - Override package directory (for Nix/Guix store paths)
PI_SERVER_DIR - Experimental server profile and socket directory (default: ~/.pi/server)
PI_SERVER_ID - Logical experimental server ID (overridden by --server-id)
PI_OFFLINE - Disable startup network operations when set to 1/true/yes
PI_TELEMETRY - Override install telemetry when set to 1/true/yes or 0/false/no
PI_SHARE_VIEWER_URL - Base URL for /share command (default: https://pi.dev/session/)

View file

@ -1,5 +1,6 @@
import { isServerId, type ServerId } from "@earendil-works/pi-protocol";
import type { AuthInput } from "../auth.ts";
import { Command } from "../command.ts";
import { Command, stringOption, valueOption } from "../command.ts";
import {
authTokenFileOption,
authTokenOption,
@ -14,6 +15,8 @@ export interface ServerCommand {
readonly command: "server";
readonly auth?: AuthInput;
readonly listen?: readonly TransportAddress[];
readonly serverId?: ServerId;
readonly sessionDir?: string;
}
export interface ServerCommandContext {
@ -21,14 +24,24 @@ export interface ServerCommandContext {
}
const listenOption = transportOption("--listen");
const serverIdOption = valueOption("--server-id", (value) =>
isServerId(value)
? { ok: true, value }
: { ok: false, error: `Invalid --server-id "${value}"; expected a lowercase UUIDv4` },
);
const sessionDirOption = stringOption("--session-dir");
export const serverCommand = new Command<ServerCommand, ServerCommandContext>("server")
.option(listenOption)
.option(serverIdOption)
.option(sessionDirOption)
.option(authTokenOption)
.option(authTokenFileOption)
.build((input) => {
const { auth, errors: authErrors } = parseAuth(input);
const listen = input.values(listenOption);
const serverId = input.value(serverIdOption);
const sessionDir = input.value(sessionDirOption);
const { errors: optionErrors } = parseLegacyOptions(input);
const errors = [...authErrors, ...optionErrors, ...unsupportedLegacyOptions("server", input)];
if (errors.length > 0) return { ok: false, errors };
@ -38,6 +51,8 @@ export const serverCommand = new Command<ServerCommand, ServerCommandContext>("s
command: "server",
...(auth === undefined ? {} : { auth }),
...(listen.length === 0 ? {} : { listen }),
...(serverId === undefined ? {} : { serverId }),
...(sessionDir === undefined ? {} : { sessionDir }),
},
};
})

View file

@ -0,0 +1,346 @@
import type { ChildProcess } from "node:child_process";
import { randomUUID } from "node:crypto";
import { isAbsolute } from "node:path";
import type { JsonlSessionMetadata } from "@earendil-works/pi-agent-core";
import type { HostedHarnessHandle } from "@earendil-works/pi-server";
import Type, { type Static } from "typebox";
import { Check } from "typebox/value";
import type { CoordinatorServer, CoordinatorServerEvent } from "./coordinator-client.ts";
import { spawnInternalProcess } from "./internal-process-launcher.ts";
import {
SESSION_WORKER_CONTROL_ADDRESS_ENV,
SESSION_WORKER_CONTROL_TOKEN_ENV,
SESSION_WORKER_COORDINATED_ENV,
SESSION_WORKER_PEER_ID_ENV,
SESSION_WORKER_SESSION_KEY_ENV,
} 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 JsonlSessionMetadataSchema = Type.Object({
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()),
legacyParentSessionPath: Type.Optional(Type.String()),
});
const WorkerMessageSchema = Type.Union([
Type.Object({
type: Type.Literal("worker_ready"),
token: Type.String(),
sessionKey: Type.String(),
sessionId: Type.String(),
pid: Type.Integer({ minimum: 1 }),
metadata: JsonlSessionMetadataSchema,
}),
Type.Object({
type: Type.Literal("worker_failed"),
token: Type.String(),
sessionKey: Type.String(),
message: Type.String(),
}),
]);
type WorkerMessage = Static<typeof WorkerMessageSchema>;
interface WorkerRecord {
readonly peerId: string;
readonly metadata: JsonlSessionMetadata;
readonly pid: number;
readonly terminated: Promise<Error | undefined>;
resolveTerminated(error: Error | undefined): void;
expectedStop: boolean;
}
interface PendingLaunch {
readonly sessionKey: string;
readonly peerId: string;
readonly token: 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 Pi server process. */
export class CoordinatedSessionWorkers {
readonly workerPids = new Map<string, number>();
readonly #coordinator: CoordinatorServer;
readonly #sessionDir: string;
readonly #workersBySession = new Map<string, WorkerRecord>();
readonly #workersByPeer = new Map<string, WorkerRecord>();
readonly #pending = new Map<string, PendingLaunch>();
readonly #removeListener: () => void;
#discoveryPeers?: Set<string>;
#resolveDiscovery?: () => void;
#detached = false;
#shuttingDown = false;
constructor(coordinator: CoordinatorServer, sessionDir: string) {
this.#coordinator = coordinator;
this.#sessionDir = sessionDir;
this.#removeListener = coordinator.onEvent((event) => this.#handleCoordinatorEvent(event));
}
get trackedSessions(): readonly JsonlSessionMetadata[] {
return [...this.#workersBySession.values()].map((worker) => worker.metadata);
}
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 createHarness(metadata: JsonlSessionMetadata): Promise<HostedHarnessHandle> {
if (this.#detached || this.#shuttingDown) throw new Error("Experimental server is shutting down");
const existing = this.#workersBySession.get(metadata.path);
if (existing) return { terminated: existing.terminated, close: async () => {} };
const pending = this.#pending.get(metadata.path);
if (pending) {
const worker = await pending.promise;
return { terminated: worker.terminated, close: async () => {} };
}
const worker = await this.#launch(metadata);
return { terminated: worker.terminated, close: async () => {} };
}
async shutdown(): Promise<void> {
if (this.#detached || this.#shuttingDown) return;
this.#shuttingDown = true;
for (const pending of this.#pending.values()) {
void this.#coordinator.send(pending.peerId, { type: "shutdown" }).catch(() => {});
}
const workers = [...this.#workersBySession.values()];
for (const worker of workers) {
worker.expectedStop = true;
void this.#coordinator.send(worker.peerId, { type: "shutdown" }).catch(() => {});
}
const finished = Promise.all([
...workers.map((worker) => worker.terminated),
...[...this.#pending.values()].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 timedOut = new Promise<boolean>((resolve) => {
timer = setTimeout(() => resolve(true), WORKER_SHUTDOWN_TIMEOUT_MS);
timer.unref();
});
if ((await Promise.race([finished.then(() => false), timedOut])) === true) {
for (const worker of workers) {
try {
process.kill(worker.pid, "SIGKILL");
} catch (error) {
if (!(error instanceof Error && "code" in error && error.code === "ESRCH")) throw error;
}
}
for (const pending of this.#pending.values()) pending.child.kill("SIGKILL");
await finished;
}
if (timer) clearTimeout(timer);
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"));
}
this.#detachState();
}
#launch(metadata: JsonlSessionMetadata): Promise<WorkerRecord> {
const sessionKey = metadata.path;
const peerId = `worker-${randomUUID()}`;
const token = randomUUID();
let child: ChildProcess;
try {
child = spawnInternalProcess("session-worker", [this.#sessionDir, JSON.stringify(metadata)], {
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_COORDINATED_ENV]: "1",
[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, child, timer, promise, resolve, reject };
this.#pending.set(sessionKey, pending);
child.once("error", (error) => this.#failPending(sessionKey, error));
child.once("exit", (code, signal) => this.#childExited(pending, code, signal));
return promise;
}
#handleCoordinatorEvent(event: CoordinatorServerEvent): 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" || !Check(WorkerMessageSchema, event.payload)) return;
const message: WorkerMessage = 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;
}
this.#recordReadyWorker(event.from, message);
}
#recordReadyWorker(peerId: string, message: Extract<WorkerMessage, { 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 existing = this.#workersBySession.get(message.sessionKey);
if (existing && existing.peerId !== peerId) {
void this.#coordinator.send(peerId, { type: "shutdown" }).catch(() => {});
return;
}
const pending = this.#pending.get(message.sessionKey);
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,
terminated,
resolveTerminated,
expectedStop: 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);
}
}
#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);
if (pending.child.exitCode === null && pending.child.signalCode === null) pending.child.kill("SIGKILL");
pending.reject(error);
}
#removeWorker(worker: WorkerRecord, error: Error | undefined): void {
if (this.#workersByPeer.get(worker.peerId) !== worker) return;
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);
}
#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();
this.#pending.clear();
this.#workersByPeer.clear();
this.#workersBySession.clear();
this.workerPids.clear();
this.#discoveryPeers = undefined;
this.#resolveDiscovery?.();
this.#resolveDiscovery = undefined;
}
}

View file

@ -0,0 +1,255 @@
import { randomUUID } from "node:crypto";
import { createConnection, type Socket } from "node:net";
import Type, { type Static } from "typebox";
import { Check } from "typebox/value";
import { spawnInternalProcess } from "./internal-process-launcher.ts";
const COORDINATOR_PROTOCOL_VERSION = 1;
const COORDINATOR_START_TIMEOUT_MS = 10_000;
const COORDINATOR_RETRY_MS = 10;
const MAX_CONTROL_LINE_BYTES = 1024 * 1024;
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 CoordinatorServerEvent =
| { 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 CoordinatorServerOptions {
readonly controlPath: string;
readonly endpoint: string;
readonly serverConnectionId?: string;
}
/** The server-side endpoint of the coordinator's intentionally opaque message router. */
export class CoordinatorServer {
readonly serverConnectionId: string;
readonly replaced: Promise<void>;
readonly peerIds = new Set<string>();
readonly #controlPath: string;
readonly #endpoint: string;
readonly #listeners = new Set<(event: CoordinatorServerEvent) => void>();
#socket?: Socket;
#registered = false;
#closed = false;
#replacedValue = false;
#resolveRegistered?: () => void;
#rejectRegistered?: (error: Error) => void;
#resolveReplaced!: () => void;
constructor(options: CoordinatorServerOptions) {
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: CoordinatorServerEvent) => 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: CoordinatorServerEvent): 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 ensureExperimentalCoordinator(
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(`${JSON.stringify(message)}\n`, (error) => {
if (error) reject(error);
else resolve();
});
});
}

View file

@ -0,0 +1,11 @@
#!/usr/bin/env node
import { runCoordinatorProcess } from "./coordinator-process.ts";
import { consumeInternalProcessRole } from "./internal-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);
});

View file

@ -0,0 +1,336 @@
import { chmod, lstat, unlink } from "node:fs/promises";
import { createConnection, createServer, type Server, type Socket } from "node:net";
// This process is intentionally a transport shim. It depends only on Node
// built-ins and never interprets Pi, session, worker, or lifecycle payloads.
const COORDINATOR_PROTOCOL_VERSION = 1;
const MAX_CONTROL_LINE_BYTES = 1024 * 1024;
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;
attachJsonLineReader(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;
if (peer && peers.get(peer.peerId) === peer) {
peers.delete(peer.peerId);
if (currentServer) writeJsonLine(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();
writeJsonLine(socket, {
type: "server_registered",
serverConnectionId,
peers: [...peers.keys()],
});
if (previous && previous !== server) {
closePublicConnections();
writeJsonLine(previous.socket, { type: "server_replaced" });
}
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();
writeJsonLine(socket, { type: "peer_registered", peerId });
if (currentServer) {
writeJsonLine(currentServer.socket, { type: "peer_connected", peerId });
}
return peer;
}
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) writeJsonLine(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()) {
writeJsonLine(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 writeJsonLine(socket: Socket, message: unknown): void {
if (!socket.destroyed) socket.write(`${JSON.stringify(message)}\n`);
}
function attachJsonLineReader(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;
});
}

View file

@ -1,9 +0,0 @@
// Prototype-only shared catalog. Remove this once parent and worker resolve
// sessions from the same durable repository.
export const DEMO_SESSION_IDS = ["demo-1", "demo-2"] as const;
export type DemoSessionId = (typeof DEMO_SESSION_IDS)[number];
export function isDemoSessionId(value: string): value is DemoSessionId {
return DEMO_SESSION_IDS.some((sessionId) => sessionId === value);
}

View file

@ -0,0 +1,46 @@
import { type ChildProcess, spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { isBunBinary } from "../../config.ts";
import { INTERNAL_PROCESS_ENV, type InternalProcessRole } from "./internal-process.ts";
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 child = spawn(
process.execPath,
isBunBinary ? [...args] : [fileURLToPath(defaultEntryUrl(role, options.entryUrl)), ...args],
{
cwd: process.cwd(),
detached: true,
env: {
...process.env,
...options.env,
[INTERNAL_PROCESS_ENV]: role,
},
stdio: "ignore",
windowsHide: true,
},
);
child.unref();
return child;
}
function defaultEntryUrl(role: InternalProcessRole, override: URL | undefined): URL {
if (override) return override;
const javaScript = import.meta.url.endsWith(".js");
if (role === "coordinator") {
return new URL(javaScript ? "coordinator-process-entry.js" : "coordinator-process-entry.ts", import.meta.url);
}
return new URL(javaScript ? "session-worker-process-entry.js" : "session-worker-process-entry.ts", import.meta.url);
}

View file

@ -0,0 +1,18 @@
export const INTERNAL_PROCESS_ENV = "__PI_INTERNAL_SPAWN";
export type InternalProcessRole = "coordinator" | "session-worker";
/** 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 === "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;
}

View file

@ -1,26 +1,28 @@
import { randomUUID } from "node:crypto";
import { chmod, lstat, mkdir } from "node:fs/promises";
import { homedir } from "node:os";
import { basename, join } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { MemorySessionRepo } from "@earendil-works/pi-agent-core";
import { type JsonlSessionMetadata, JsonlSessionRepo } from "@earendil-works/pi-agent-core";
import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
import { PiClient } from "@earendil-works/pi-client";
import { requestServerDrain } from "@earendil-works/pi-client/control";
import { createUnixTransportFactory, discoverUnixServers, type UnixServerRoute } from "@earendil-works/pi-client/unix";
import { isServerId } from "@earendil-works/pi-protocol";
import type { PiServer, PiServerHost } from "@earendil-works/pi-server";
import { isServerId, type ServerId } from "@earendil-works/pi-protocol";
import type { HostedHarnessHandle, PiServer, PiServerHost } from "@earendil-works/pi-server";
import { createUnixServer, getUnixSocketPath } from "@earendil-works/pi-server/unix";
import { getAgentDir } from "../../config.ts";
import { resolvePath } from "../../utils/paths.ts";
import type { ClientCommand } from "./commands/client.ts";
import { DEMO_SESSION_IDS } from "./demo-sessions.ts";
import { CoordinatedSessionWorkers } from "./coordinated-session-workers.ts";
import {
CoordinatorServer,
type CoordinatorStartupLease,
ensureExperimentalCoordinator,
} from "./coordinator-client.ts";
import { ENV_SERVER_ID, ensurePrivateServerDirectory, resolveExperimentalServerDirectory } from "./server-directory.ts";
import { acquireExperimentalServerProfile } from "./server-profile.ts";
import { startExperimentalSessionWorker } from "./session-worker.ts";
const SOCKET_RELEASE_TIMEOUT_MS = 10_000;
const SOCKET_RELEASE_POLL_MS = 10;
const EXPERIMENTAL_SOCKET_ROOT = "/tmp";
export interface ExperimentalMemoryServer {
export interface ExperimentalServer {
readonly serverId: string;
readonly sessionDir: string;
readonly socketPath: string;
readonly server: PiServer;
readonly workerPids: ReadonlyMap<string, number>;
@ -35,50 +37,54 @@ export type ExperimentalClientResult =
}
| { readonly kind: "attached"; readonly serverId: string; readonly sessionId: string };
export interface StartExperimentalMemoryServerOptions {
/** Directory for server-addressed Unix sockets. Defaults to a short, private per-user runtime directory. */
export interface StartExperimentalServerOptions {
/** Directory for server sockets. Defaults to PI_SERVER_DIR or ~/.pi/server. */
readonly directory?: string;
readonly path?: string;
readonly serverId?: string;
readonly serverId?: ServerId;
/** Durable session directory. Defaults to the experimental directory under the configured agent directory. */
readonly sessionDir?: string;
}
export interface StartExperimentalServerGenerationOptions {
/** Persistent profile directory. Defaults to ~/.pi/server. */
export interface StartExperimentalCoordinatedServerOptions {
/** Server profile and socket directory. Defaults to PI_SERVER_DIR or ~/.pi/server. */
readonly directory?: string;
/** Physical socket directory. Defaults to a short, private per-user runtime directory. */
readonly socketDirectory?: 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;
}
export interface RunExperimentalClientOptions {
/** Directory searched when --connect is omitted. Defaults to the experimental per-user runtime directory. */
/** Directory searched when --connect is omitted. Defaults to PI_SERVER_DIR or ~/.pi/server. */
readonly directory?: string;
}
/** Start the temporary in-memory list-and-attach server composition. */
export async function startExperimentalMemoryServer(
options: StartExperimentalMemoryServerOptions = {},
): Promise<ExperimentalMemoryServer> {
const serverId = options.serverId ?? randomUUID();
const repo = new MemorySessionRepo();
for (const id of DEMO_SESSION_IDS) {
const session = await repo.create({ id });
await session.close();
}
export function resolveExperimentalSessionDirectory(sessionDir?: string): string {
return resolvePath(sessionDir ?? join(getAgentDir(), "experimental", "sessions"));
}
interface ExperimentalWorkerController {
readonly workerPids: ReadonlyMap<string, number>;
readonly trackedSessions: readonly JsonlSessionMetadata[];
createHarness(metadata: JsonlSessionMetadata): Promise<HostedHarnessHandle>;
}
/** Start the experimental durable list-and-attach server composition. */
export async function startExperimentalServer(
options: StartExperimentalServerOptions = {},
): Promise<ExperimentalServer> {
const sessionDir = resolveExperimentalSessionDirectory(options.sessionDir);
const workerPids = new Map<string, number>();
const host: PiServerHost = {
sessions: repo,
createHarness: async (session) => {
const sessionId = session.metadata.id;
const worker = await startExperimentalSessionWorker(sessionId);
try {
// Prototype-only adapter: the server opened this parent-repository
// facade, while the child owns its independently restored Session.
await session.close();
} catch (error) {
await worker.close();
throw error;
}
const controller: ExperimentalWorkerController = {
workerPids,
trackedSessions: [],
createHarness: async (metadata) => {
const sessionId = metadata.id;
const worker = await startExperimentalSessionWorker(metadata, {
sessionDir,
controlDirectory: options.directory,
});
workerPids.set(sessionId, worker.pid);
return {
terminated: worker.terminated.then((error) => {
@ -95,17 +101,44 @@ export async function startExperimentalMemoryServer(
};
},
};
return startExperimentalServerBackend(options, controller);
}
async function startExperimentalServerBackend(
options: StartExperimentalServerOptions,
workers: ExperimentalWorkerController,
): Promise<ExperimentalServer> {
const serverId = options.serverId ?? randomUUID();
const sessionDir = resolveExperimentalSessionDirectory(options.sessionDir);
const executionEnv = new NodeExecutionEnv({ cwd: process.cwd() });
const repo = new JsonlSessionRepo({ fileSystem: executionEnv, sessionsRoot: sessionDir });
const host: PiServerHost<JsonlSessionMetadata> = {
sessions: {
list: async () => {
const sessions = new Map((await repo.list()).map((metadata) => [metadata.path, metadata]));
for (const metadata of workers.trackedSessions) sessions.set(metadata.path, metadata);
return [...sessions.values()];
},
},
createHarness: (metadata) => workers.createHarness(metadata),
};
let socketPath = options.path;
if (socketPath === undefined) {
const socketDirectory = options.directory ?? getExperimentalSocketDirectory();
await ensurePrivateSocketDirectory(socketDirectory);
socketPath = getUnixSocketPath(serverId, socketDirectory);
const serverDirectory = resolveExperimentalServerDirectory(options.directory);
await ensurePrivateServerDirectory(serverDirectory);
socketPath = getUnixSocketPath(serverId, serverDirectory);
}
const closeCatalog = async (): Promise<void> => {
const cleanup = await Promise.allSettled([repo.close(), executionEnv.cleanup()]);
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 });
try {
await server.start();
} catch (error) {
const cleanup = await Promise.allSettled([server.close(), repo.close()]);
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");
@ -115,10 +148,10 @@ export async function startExperimentalMemoryServer(
let closePromise: Promise<void> | undefined;
const closed = server.closed.then(
() => repo.close(),
() => closeCatalog(),
async (serverError: unknown) => {
try {
await repo.close();
await closeCatalog();
} catch (repoError) {
throw new AggregateError([serverError, repoError], "Server and repository shutdown failed");
}
@ -127,73 +160,106 @@ export async function startExperimentalMemoryServer(
);
return {
serverId,
sessionDir,
socketPath,
server,
workerPids,
workerPids: workers.workerPids,
closed,
close() {
if (closePromise === undefined)
closePromise = server.close().then(
() => closed,
() => closed,
);
closePromise ??= server.close().then(
() => closed,
() => closed,
);
return closePromise;
},
};
}
/** Start the experimental local server, replacing an existing generation for the same profile. */
export async function startExperimentalServerGeneration(
options: StartExperimentalServerGenerationOptions = {},
): Promise<ExperimentalMemoryServer> {
const directory = options.directory ?? join(homedir(), ".pi", "server");
const socketDirectory = options.socketDirectory ?? getExperimentalSocketDirectory();
const { serverId, release } = await acquireExperimentalServerProfile(directory);
let runtime: ExperimentalMemoryServer;
/** Start a replaceable experimental server behind the stable coordinator endpoint. */
export async function startExperimentalCoordinatedServer(
options: StartExperimentalCoordinatedServerOptions = {},
): Promise<ExperimentalServer> {
const directory = resolveExperimentalServerDirectory(options.directory);
const { serverId, release } = await acquireExperimentalServerProfile(
directory,
options.serverId ?? process.env[ENV_SERVER_ID],
);
let backend: ExperimentalServer | undefined;
let coordinator: CoordinatorServer | undefined;
let startupLease: CoordinatorStartupLease | undefined;
let workers: CoordinatedSessionWorkers | undefined;
let released = false;
try {
await ensurePrivateSocketDirectory(socketDirectory);
const socketPath = getUnixSocketPath(serverId, socketDirectory);
await drainExistingGeneration(serverId, socketPath);
runtime = await startExperimentalMemoryServer({
directory,
path: socketPath,
serverId,
});
} catch (error) {
try {
await release();
} catch (releaseError) {
throw new AggregateError([error, releaseError], "Server generation failed and launcher lock release failed");
}
throw error;
}
await ensurePrivateServerDirectory(directory);
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 ensureExperimentalCoordinator(socketPath, controlPath);
coordinator = new CoordinatorServer({ controlPath, endpoint: serverPath });
const sessionDir = resolveExperimentalSessionDirectory(options.sessionDir);
workers = new CoordinatedSessionWorkers(coordinator, sessionDir);
backend = await startExperimentalServerBackend(
{ path: serverPath, serverId, sessionDir: options.sessionDir },
workers,
);
await coordinator.connect();
startupLease.close();
startupLease = undefined;
await workers.discover(coordinator.peerIds);
try {
const activeBackend = backend;
const activeCoordinator = coordinator;
const activeWorkers = workers;
void activeCoordinator.replaced
.then(async () => {
activeWorkers.detach();
await activeBackend.close();
})
.finally(() => activeCoordinator.close())
.catch(() => {});
let closePromise: Promise<void> | undefined;
const runtime: ExperimentalServer = {
serverId,
sessionDir: activeBackend.sessionDir,
socketPath,
server: activeBackend.server,
workerPids: activeWorkers.workerPids,
closed: activeBackend.closed,
close() {
closePromise ??= (async () => {
try {
await activeBackend.close();
} finally {
try {
if (activeCoordinator.wasReplaced) activeWorkers.detach();
else await activeWorkers.shutdown();
} finally {
activeCoordinator.close();
}
}
})();
return closePromise;
},
};
released = true;
await release();
return runtime;
} catch (error) {
try {
await runtime.close();
} catch (closeError) {
throw new AggregateError([error, closeError], "Launcher lock release and server cleanup failed");
startupLease?.close();
if (coordinator?.wasReplaced) workers?.detach();
const cleanup = await Promise.allSettled([
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], "Coordinated server startup and cleanup failed");
}
throw error;
}
return runtime;
}
async function drainExistingGeneration(serverId: string, socketPath: string): Promise<void> {
try {
await requestServerDrain({
serverId,
transportFactory: createUnixTransportFactory({ path: socketPath }),
});
await waitForSocketRelease(socketPath);
} catch (error) {
// At this launcher boundary, a missing or refused stable socket proves that
// no generation accepted the drain request. All ambiguous failures propagate.
if (hasErrorCode(error, "ENOENT") || hasErrorCode(error, "ECONNREFUSED")) return;
throw error;
}
}
/** Discover local servers, then list sessions or attach to one selected session. */
@ -201,10 +267,10 @@ 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");
if (command.auth !== undefined) throw new Error("Authentication is not supported by the experimental local server");
const routes = command.connect
? [routeFromExplicitPath(command.connect.path)]
: await discoverUnixServers({ directory: options.directory ?? getExperimentalSocketDirectory() });
: await discoverUnixServers({ directory: resolveExperimentalServerDirectory(options.directory) });
const discovered: { route: UnixServerRoute; sessionIds: string[] }[] = [];
for (const route of routes) {
@ -259,45 +325,3 @@ function routeFromExplicitPath(path: string): UnixServerRoute {
}
return { serverId, path };
}
async function waitForSocketRelease(path: string): Promise<void> {
const deadline = Date.now() + SOCKET_RELEASE_TIMEOUT_MS;
while (true) {
try {
await lstat(path);
} catch (error) {
if (hasErrorCode(error, "ENOENT")) return;
throw error;
}
if (Date.now() >= deadline) throw new Error(`Timed out waiting for server socket to close: ${path}`);
await delay(SOCKET_RELEASE_POLL_MS);
}
}
function getExperimentalSocketDirectory(): string {
if (process.platform === "win32" || typeof process.getuid !== "function") {
throw new Error("Experimental Unix server transport requires a POSIX user ID");
}
return join(EXPERIMENTAL_SOCKET_ROOT, `pi-server-${process.getuid()}`);
}
async function ensurePrivateSocketDirectory(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);
}
function hasErrorCode(error: unknown, code: string): boolean {
let current = error;
const seen = new Set<unknown>();
while (current instanceof Error && !seen.has(current)) {
seen.add(current);
if ("code" in current && current.code === code) return true;
current = current.cause;
}
return false;
}

View file

@ -0,0 +1,22 @@
import { chmod, lstat, mkdir } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import { resolvePath } from "../../utils/paths.ts";
export const ENV_SERVER_DIR = "PI_SERVER_DIR";
export const ENV_SERVER_ID = "PI_SERVER_ID";
export function resolveExperimentalServerDirectory(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);
}

View file

@ -7,55 +7,57 @@ import lockfile from "proper-lockfile";
const LOCK_STALE_MS = 30_000;
const LOCK_RETRY_MS = 25;
const LOCK_WAIT_MS = 30_000;
const SERVER_ID_FILE = "server-id";
const DEFAULT_SERVER_ID_FILE = "default-server-id";
export interface ExperimentalServerProfile {
readonly serverId: ServerId;
release(): Promise<void>;
}
/** Lock one experimental server profile and load or create its stable identity. */
export async function acquireExperimentalServerProfile(directory: string): Promise<ExperimentalServerProfile> {
/** Lock one logical server ID in a shared experimental server directory. */
export async function acquireExperimentalServerProfile(
directory: string,
requestedServerId?: string,
): Promise<ExperimentalServerProfile> {
await mkdir(directory, { recursive: true, mode: 0o700 });
const release = await lockfile.lock(directory, {
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,
lockfilePath: join(directory, ".launcher.lock"),
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,
},
});
try {
return { serverId: await loadOrCreateServerId(directory), release };
} catch (error) {
try {
await release();
} catch (releaseError) {
throw new AggregateError([error, releaseError], "Server profile loading and lock release failed");
}
throw error;
}
}
async function loadOrCreateServerId(directory: string): Promise<ServerId> {
const path = join(directory, SERVER_ID_FILE);
try {
const value = (await readFile(path, "utf8")).trim();
if (!isServerId(value)) throw new Error(`Invalid experimental server identity in ${path}`);
return value;
} catch (error) {
if (!isErrorCode(error, "ENOENT")) throw error;
}
const serverId = randomUUID();
await writeFile(path, serverId, { encoding: "utf8", mode: 0o600, flag: "wx" });
return serverId;
}
function isErrorCode(error: unknown, code: string): boolean {
return error instanceof Error && "code" in error && error.code === code;
return { serverId, release };
}

View file

@ -0,0 +1,10 @@
#!/usr/bin/env node
import { consumeInternalProcessRole } from "./internal-process.ts";
import { runSessionWorkerProcess } from "./session-worker-process.ts";
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));

View file

@ -1,56 +1,274 @@
import { type AgentHarness, MemorySessionRepo } from "@earendil-works/pi-agent-core";
import { isDemoSessionId } from "./demo-sessions.ts";
import { createConnection, type Socket } from "node:net";
import { isAbsolute } from "node:path";
import {
AgentHarness,
type AgentHarness as AgentHarnessInstance,
type JsonlSessionMetadata,
JsonlSessionRepo,
type Session,
} 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 { ModelRuntime } from "../../core/model-runtime.ts";
import {
SESSION_WORKER_CONTROL_ADDRESS_ENV,
SESSION_WORKER_CONTROL_TOKEN_ENV,
SESSION_WORKER_COORDINATED_ENV,
SESSION_WORKER_PEER_ID_ENV,
SESSION_WORKER_SESSION_KEY_ENV,
} from "./session-worker.ts";
// Prototype-only control protocol. Real Harness operations will require a
// transport-independent protocol rather than additional ad hoc IPC messages.
// Process lifecycle control only. Agent operations use the shared
// transport-independent protocol rather than this channel.
export type SessionWorkerCommand = { type: "shutdown" };
export type SessionWorkerEvent =
| { type: "ready"; sessionId: string; pid: number }
| { type: "failed"; message: string };
function send(event: SessionWorkerEvent): Promise<void> {
return new Promise((resolve, reject) => {
if (!process.send) {
reject(new Error("Session worker requires an IPC channel"));
const WorkerMetadataSchema = Type.Object({
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()),
legacyParentSessionPath: Type.Optional(Type.String()),
});
const CoordinatorInputSchema = Type.Union([
Type.Object({ type: Type.Literal("peer_registered"), peerId: Type.String() }),
Type.Object({ type: Type.Literal("message"), from: Type.Literal("server"), payload: Type.Unknown() }),
]);
const WorkerCommandSchema = Type.Union([
Type.Object({ type: Type.Literal("shutdown") }),
Type.Object({ type: Type.Literal("discover_workers") }),
]);
type CoordinatorInput = Static<typeof CoordinatorInputSchema>;
type WorkerCommand = Static<typeof WorkerCommandSchema>;
type SessionWorkerControlEvent =
| {
type: "register_worker";
protocol: 1;
token: string;
sessionKey: string;
sessionId: string;
pid: number;
}
| {
type: "worker_ready";
token: string;
sessionKey: string;
sessionId: string;
pid: number;
metadata: JsonlSessionMetadata;
}
| { type: "worker_failed"; token: string; sessionKey: string; message: string };
interface WorkerControl {
readonly coordinated: boolean;
readonly messages: AsyncIterable<unknown>;
readonly socket: Socket;
send(event: SessionWorkerControlEvent): 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 socket = createConnection(address);
await new Promise<void>((resolve, reject) => {
socket.once("connect", resolve);
socket.once("error", reject);
});
const messages = createJsonLineMessages(socket);
const coordinated = process.env[SESSION_WORKER_COORDINATED_ENV] === "1";
if (coordinated) {
const peerId = process.env[SESSION_WORKER_PEER_ID_ENV];
if (!peerId) throw new Error("Coordinated session worker requires a peer ID");
await writeJsonLine(socket, { type: "register_peer", protocol: 1, 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 {
coordinated,
messages,
socket,
send: (event) => writeJsonLine(socket, coordinated ? { type: "send", to: "server", payload: event } : event),
};
}
async function readCommands(control: WorkerControl, onShutdown: () => void, onDiscovery: () => void): Promise<void> {
for await (const value of control.messages) {
if (!control.coordinated) {
if (Check(WorkerCommandSchema, value) && value.type === "shutdown") onShutdown();
continue;
}
if (!Check(CoordinatorInputSchema, value)) {
control.socket.destroy(new Error("Coordinator sent an invalid worker message"));
return;
}
process.send(event, (error) => {
const message: CoordinatorInput = value;
if (message.type !== "message" || !Check(WorkerCommandSchema, message.payload)) continue;
const command: WorkerCommand = message.payload;
if (command.type === "shutdown") onShutdown();
else onDiscovery();
}
}
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;
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(`${JSON.stringify(message)}\n`, (error) => {
if (error) reject(error);
else resolve();
});
});
}
async function run(): Promise<void> {
// Prototype-only catalog validation. A production worker will resolve the
// requested session through durable storage.
const sessionId = process.argv[2];
if (!sessionId || !isDemoSessionId(sessionId)) throw new Error(`Unknown demo session: ${sessionId ?? ""}`);
function parseMetadata(value: string | undefined): JsonlSessionMetadata {
if (value === undefined) throw new Error("Session worker requires session metadata");
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch (error) {
throw new Error("Session worker received invalid session metadata", { cause: error });
}
if (!Check(WorkerMetadataSchema, parsed) || !isAbsolute(parsed.cwd) || !isAbsolute(parsed.path)) {
throw new Error("Session worker received invalid session metadata");
}
return parsed;
}
// Prototype-only isolated state. The parent and child intentionally seed
// separate repositories; this does not provide persistence or shared state.
const repo = new MemorySessionRepo();
// Seed, close, list, and reopen to model restoring an existing Session
// instead of handing the freshly created facade directly to the Harness.
const created = await repo.create({ id: sessionId });
await created.close();
const metadata = (await repo.list()).find((candidate) => candidate.id === sessionId);
if (!metadata) throw new Error(`Unknown demo session: ${sessionId}`);
const session = await repo.open(metadata);
// Prototype-only close-capable Harness placeholder. A production worker will
// construct and own the real AgentHarness for this durable Session.
const harnessOwner: Pick<AgentHarness, "close"> = { close: () => session.close() };
async function closeResources(resources: {
harness?: AgentHarnessInstance;
session?: Session<JsonlSessionMetadata>;
repo: JsonlSessionRepo;
executionEnv: NodeExecutionEnv;
releaseOwnership: () => Promise<void>;
}): Promise<void> {
const errors: unknown[] = [];
try {
if (resources.harness) await resources.harness.close();
else await resources.session?.close();
} catch (error) {
errors.push(error);
}
try {
await resources.repo.close();
} catch (error) {
errors.push(error);
}
try {
await resources.executionEnv.cleanup();
} 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");
}
async function run(args: readonly string[]): Promise<void> {
const sessionDir = args[0];
if (!sessionDir || !isAbsolute(sessionDir)) throw new Error("Session worker requires an absolute session directory");
const metadata = parseMetadata(args[1]);
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;
if (!control.coordinated) {
await control.send({
type: "register_worker",
protocol: 1,
token,
sessionKey,
sessionId,
pid: process.pid,
});
}
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;
try {
session = await repo.open(metadata);
const modelRuntime = await ModelRuntime.create();
const model = modelRuntime.getAvailableSnapshot()[0];
if (!model) throw new Error("Session worker could not find a configured model");
({ harness } = await AgentHarness.create({ session, models: modelRuntime, model, tools: [], resources: {} }));
} catch (error) {
try {
await closeResources({ harness, session, repo, executionEnv, releaseOwnership });
} catch (cleanupError) {
throw new AggregateError([error, cleanupError], "Session worker startup and cleanup failed");
}
throw error;
}
let closing: Promise<void> | undefined;
const close = (): Promise<void> => {
if (!closing) {
closing = harnessOwner.close().finally(() => repo.close());
}
closing ??= closeResources({ harness, repo, executionEnv, releaseOwnership });
return closing;
};
process.on("message", (message: SessionWorkerCommand) => {
if (message?.type !== "shutdown") return;
const closeAndExit = (): void => {
void close().then(
() => process.exit(0),
(error: unknown) => {
@ -58,16 +276,52 @@ async function run(): Promise<void> {
process.exit(1);
},
);
});
process.once("disconnect", () => void close().finally(() => process.exit(0)));
process.once("SIGTERM", () => void close().finally(() => process.exit(0)));
process.once("SIGINT", () => void close().finally(() => process.exit(0)));
};
await send({ type: "ready", sessionId, pid: process.pid });
let ready = false;
const announce = (): void => {
if (!ready) return;
void control
.send({
type: "worker_ready",
token,
sessionKey,
sessionId,
pid: process.pid,
metadata,
})
.catch(() => closeAndExit());
};
void readCommands(control, closeAndExit, announce);
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 });
} catch (error) {
try {
await close();
} catch (cleanupError) {
throw new AggregateError([error, cleanupError], "Session worker readiness and cleanup failed");
}
throw error;
}
}
void run().catch(async (error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
await send({ type: "failed", message }).catch(() => {});
process.exit(1);
});
export async function runSessionWorkerProcess(args: readonly string[]): Promise<void> {
try {
await run(args);
} 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;
}
}

View file

@ -1,9 +1,35 @@
import { type ChildProcess, fork } from "node:child_process";
import type { ChildProcess } from "node:child_process";
import { randomUUID } from "node:crypto";
import { chmod, unlink } from "node:fs/promises";
import { createServer, type Server, type Socket } from "node:net";
import { isAbsolute, join } from "node:path";
import type { JsonlSessionMetadata } from "@earendil-works/pi-agent-core";
import Type, { type Static } from "typebox";
import { Check } from "typebox/value";
import { spawnInternalProcess } from "./internal-process-launcher.ts";
import { ensurePrivateServerDirectory, resolveExperimentalServerDirectory } from "./server-directory.ts";
import type { SessionWorkerCommand, SessionWorkerEvent } from "./session-worker-process.ts";
const DEFAULT_STARTUP_TIMEOUT_MS = 5_000;
const DEFAULT_STARTUP_TIMEOUT_MS = 10_000;
const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5_000;
const MAX_TIMER_DELAY_MS = 2_147_483_647;
const WorkerControlInputSchema = Type.Union([
Type.Object({ type: Type.Literal("worker_failed"), message: Type.String() }),
Type.Object({ type: Type.Literal("worker_ready"), sessionId: Type.String(), pid: Type.Integer({ minimum: 1 }) }),
Type.Object({
type: Type.Literal("register_worker"),
protocol: Type.Literal(1),
token: Type.String(),
sessionId: Type.String(),
pid: Type.Integer({ minimum: 1 }),
}),
]);
type WorkerControlInput = Static<typeof WorkerControlInputSchema>;
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_COORDINATED_ENV = "PI_SESSION_WORKER_COORDINATED";
export const SESSION_WORKER_PEER_ID_ENV = "PI_SESSION_WORKER_PEER_ID";
export interface ExperimentalSessionWorker {
readonly sessionId: string;
@ -13,32 +39,56 @@ export interface ExperimentalSessionWorker {
}
export interface StartExperimentalSessionWorkerOptions {
readonly sessionDir: string;
readonly controlDirectory?: string;
readonly startupTimeoutMs?: number;
readonly shutdownTimeoutMs?: number;
readonly workerUrl?: URL;
}
export async function startExperimentalSessionWorker(
sessionId: string,
options: StartExperimentalSessionWorkerOptions = {},
metadata: JsonlSessionMetadata,
options: StartExperimentalSessionWorkerOptions,
): Promise<ExperimentalSessionWorker> {
const startupTimeoutMs = validateTimeout(options.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS, "startupTimeoutMs");
const shutdownTimeoutMs = validateTimeout(
options.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS,
"shutdownTimeoutMs",
);
const workerUrl = options.workerUrl ?? defaultWorkerUrl();
const child = fork(workerUrl, [sessionId], {
execArgv: workerExecArgv(workerUrl),
stdio: ["ignore", "ignore", "inherit", "ipc"],
});
if (!isAbsolute(options.sessionDir)) throw new TypeError("Session worker sessionDir must be absolute");
const token = randomUUID();
const controlId = randomUUID();
let controlAddress: string;
if (process.platform === "win32") {
controlAddress = `\\\\.\\pipe\\pi-worker-${controlId}`;
} else {
const controlDirectory = resolveExperimentalServerDirectory(options.controlDirectory);
await ensurePrivateServerDirectory(controlDirectory);
controlAddress = join(controlDirectory, `worker-${controlId}.sock`);
}
const control = await createWorkerControlServer(controlAddress, token, metadata.id);
let child: ChildProcess;
try {
child = spawnInternalProcess("session-worker", [options.sessionDir, JSON.stringify(metadata)], {
entryUrl: options.workerUrl,
env: {
[SESSION_WORKER_CONTROL_ADDRESS_ENV]: controlAddress,
[SESSION_WORKER_CONTROL_TOKEN_ENV]: token,
[SESSION_WORKER_SESSION_KEY_ENV]: Buffer.from(metadata.path).toString("base64url"),
},
});
} catch (error) {
await control.close();
throw error;
}
try {
const ready = await waitForReady(child, sessionId, startupTimeoutMs);
return new ChildSessionWorker(child, ready.sessionId, ready.pid, shutdownTimeoutMs);
const ready = await waitForReady(child, control, metadata.id, startupTimeoutMs);
return new SpawnedSessionWorker(child, control, ready.sessionId, ready.pid, shutdownTimeoutMs);
} catch (error) {
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
await waitForExit(child);
await control.close();
throw error;
}
}
@ -50,38 +100,163 @@ function validateTimeout(value: number, name: string): number {
return value;
}
function workerExecArgv(workerUrl: URL): string[] {
return workerUrl.pathname.endsWith(".ts") ? ["--import", "tsx"] : process.execArgv;
type WorkerControllerEvent = SessionWorkerEvent | { type: "hello"; token: string; sessionId: string; pid: number };
interface WorkerControlServer {
readonly connected: Promise<Socket>;
readonly events: AsyncIterable<WorkerControllerEvent>;
send(message: SessionWorkerCommand): Promise<void>;
close(): Promise<void>;
}
function defaultWorkerUrl(): URL {
return new URL(
import.meta.url.endsWith(".js") ? "session-worker-process.js" : "session-worker-process.ts",
import.meta.url,
);
async function createWorkerControlServer(
address: string,
token: string,
expectedSessionId: string,
): Promise<WorkerControlServer> {
let accepted: Socket | undefined;
let resolveConnected!: (socket: Socket) => void;
const connected = new Promise<Socket>((resolve) => {
resolveConnected = resolve;
});
const messages: WorkerControllerEvent[] = [];
const waiters: ((message: WorkerControllerEvent) => void)[] = [];
const server = createServer((socket) => {
if (accepted) {
socket.destroy();
return;
}
accepted = socket;
resolveConnected(socket);
attachJsonLineReader(socket, (value) => {
if (!Check(WorkerControlInputSchema, value)) {
socket.destroy(new Error("Session worker sent an invalid control message"));
return;
}
const message: WorkerControlInput = value;
let event: WorkerControllerEvent;
if (message.type === "worker_failed") {
event = { type: "failed", message: message.message };
} else if (message.type === "worker_ready") {
event = { type: "ready", sessionId: message.sessionId, pid: message.pid };
} else {
event = {
type: "hello",
token: message.token,
sessionId: message.sessionId,
pid: message.pid,
};
}
if (event.type === "hello" && (event.token !== token || event.sessionId !== expectedSessionId)) {
socket.destroy(new Error("Session worker sent invalid control credentials"));
return;
}
const waiter = waiters.shift();
if (waiter) waiter(event);
else messages.push(event);
});
});
await listen(server, address);
if (process.platform !== "win32") await chmod(address, 0o600);
let closePromise: Promise<void> | undefined;
return {
connected,
events: {
[Symbol.asyncIterator]() {
return {
next: async () => {
const message =
messages.shift() ??
(await new Promise<(typeof messages)[number]>((resolve) => waiters.push(resolve)));
return { done: false as const, value: message };
},
};
},
},
async send(message) {
const socket = await connected;
await new Promise<void>((resolve, reject) => {
socket.write(`${JSON.stringify(message)}\n`, (error) => {
if (error) reject(error);
else resolve();
});
});
},
close() {
closePromise ??= closeWorkerControl(server, accepted, address);
return closePromise;
},
};
}
class ChildSessionWorker implements ExperimentalSessionWorker {
async function waitForReady(
child: ChildProcess,
control: WorkerControlServer,
expectedSessionId: string,
startupTimeoutMs: number,
): Promise<Extract<SessionWorkerEvent, { type: "ready" }>> {
const events = control.events[Symbol.asyncIterator]();
const startup = (async () => {
const hello = (await events.next()).value;
if (hello.type !== "hello" || hello.sessionId !== expectedSessionId || hello.pid !== child.pid) {
throw new Error("Session worker reported an invalid identity");
}
const event = (await events.next()).value;
if (event.type === "failed") throw new Error(`Session worker failed: ${event.message}`);
if (event.type !== "ready" || event.sessionId !== expectedSessionId || event.pid !== child.pid) {
throw new Error("Session worker reported an invalid identity");
}
return event;
})();
const exited = waitForExit(child).then(({ code, signal }) => {
throw new Error(`Session worker exited before readiness (${signal ?? code ?? "unknown"})`);
});
let timeout: NodeJS.Timeout | undefined;
const timedOut = new Promise<never>((_, reject) => {
timeout = setTimeout(
() => reject(new Error(`Session worker startup timed out after ${startupTimeoutMs}ms`)),
startupTimeoutMs,
);
timeout.unref();
});
try {
return await Promise.race([startup, exited, timedOut]);
} finally {
if (timeout) clearTimeout(timeout);
}
}
class SpawnedSessionWorker implements ExperimentalSessionWorker {
readonly terminated: Promise<Error | undefined>;
readonly sessionId: string;
readonly pid: number;
readonly #child: ChildProcess;
readonly #control: WorkerControlServer;
readonly #exit: Promise<{ code: number | null; signal: NodeJS.Signals | null }>;
readonly #shutdownTimeoutMs: number;
#closePromise?: Promise<void>;
#closeRequested = false;
constructor(child: ChildProcess, sessionId: string, pid: number, shutdownTimeoutMs: number) {
constructor(
child: ChildProcess,
control: WorkerControlServer,
sessionId: string,
pid: number,
shutdownTimeoutMs: number,
) {
this.#child = child;
this.#control = control;
this.sessionId = sessionId;
this.pid = pid;
this.#shutdownTimeoutMs = shutdownTimeoutMs;
this.#exit = waitForExit(child);
this.terminated = this.#exit.then(({ code, signal }) =>
this.#closeRequested
this.terminated = this.#exit.then(async ({ code, signal }) => {
await this.#control.close();
return this.#closeRequested
? undefined
: new Error(`Session worker ${sessionId} exited unexpectedly (${signal ?? code ?? "unknown"})`),
);
: new Error(`Session worker ${sessionId} exited unexpectedly (${signal ?? code ?? "unknown"})`);
});
}
close(): Promise<void> {
@ -91,69 +266,62 @@ class ChildSessionWorker implements ExperimentalSessionWorker {
}
async #close(): Promise<void> {
if (this.#child.exitCode !== null || this.#child.signalCode !== null) return;
if (!this.#child.connected) {
this.#child.kill("SIGKILL");
} else {
this.#child.send({ type: "shutdown" } satisfies SessionWorkerCommand, (error) => {
if (error) this.#child.kill("SIGKILL");
});
}
const timeout = setTimeout(() => this.#child.kill("SIGKILL"), this.#shutdownTimeoutMs);
timeout.unref();
try {
await this.#exit;
} finally {
clearTimeout(timeout);
if (this.#child.exitCode === null && this.#child.signalCode === null) {
await this.#control.send({ type: "shutdown" }).catch(() => this.#child.kill("SIGKILL"));
const timeout = setTimeout(() => this.#child.kill("SIGKILL"), this.#shutdownTimeoutMs);
timeout.unref();
try {
await this.#exit;
} finally {
clearTimeout(timeout);
}
}
await this.#control.close();
}
}
function waitForReady(
child: ChildProcess,
expectedSessionId: string,
startupTimeoutMs: number,
): Promise<Extract<SessionWorkerEvent, { type: "ready" }>> {
function attachJsonLineReader(socket: Socket, onMessage: (message: unknown) => void): void {
let buffered = "";
socket.setEncoding("utf8");
socket.on("data", (chunk: string) => {
buffered += chunk;
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("Session worker sent invalid JSON"));
return;
}
}
});
}
function listen(server: Server, address: string): Promise<void> {
return new Promise((resolve, reject) => {
let timeout: NodeJS.Timeout | undefined;
const cleanup = (): void => {
if (timeout) clearTimeout(timeout);
child.off("message", onMessage);
child.off("error", onError);
child.off("exit", onExit);
};
const fail = (error: Error): void => {
cleanup();
const onError = (error: Error): void => {
server.off("listening", onListening);
reject(error);
};
const onMessage = (message: SessionWorkerEvent): void => {
if (message?.type === "failed") {
fail(new Error(`Session worker failed: ${message.message}`));
return;
}
if (message?.type !== "ready") return;
if (message.sessionId !== expectedSessionId || message.pid !== child.pid) {
fail(new Error("Session worker reported an invalid identity"));
return;
}
cleanup();
resolve(message);
const onListening = (): void => {
server.off("error", onError);
resolve();
};
const onError = (error: Error): void => fail(error);
const onExit = (code: number | null, signal: NodeJS.Signals | null): void => {
fail(new Error(`Session worker exited before readiness (${signal ?? code ?? "unknown"})`));
};
timeout = setTimeout(
() => fail(new Error(`Session worker startup timed out after ${startupTimeoutMs}ms`)),
startupTimeoutMs,
);
timeout.unref();
child.on("message", onMessage);
child.once("error", onError);
child.once("exit", onExit);
server.once("error", onError);
server.once("listening", onListening);
server.listen(address);
});
}
async function closeWorkerControl(server: Server, socket: Socket | undefined, address: string): Promise<void> {
socket?.destroy();
if (server.listening) await new Promise<void>((resolve) => server.close(() => resolve()));
if (process.platform !== "win32") await unlink(address).catch(() => undefined);
}
function waitForExit(child: ChildProcess): Promise<{ code: number | null; signal: NodeJS.Signals | null }> {
if (child.exitCode !== null || child.signalCode !== null) {
return Promise.resolve({ code: child.exitCode, signal: child.signalCode });

View file

@ -29,7 +29,7 @@ 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, startExperimentalServerGeneration } from "./cli/experimental/runtime.ts";
import { runExperimentalClient, startExperimentalCoordinatedServer } 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";
@ -590,7 +590,10 @@ async function waitForTermination(serverClosed: Promise<void>): Promise<void> {
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 server-addressed Unix socket");
const runtime = await startExperimentalServerGeneration();
const runtime = await startExperimentalCoordinatedServer({
serverId: command.serverId,
sessionDir: command.sessionDir,
});
console.log(`Server: ${runtime.serverId}`);
console.log(`Socket: ${runtime.socketPath}`);
try {

View file

@ -38,6 +38,26 @@ describe("experimental CLI commands", () => {
});
});
test("parses an experimental server ID", () => {
expect(experimentalCli.parse(["server", "--server-id", "00000000-0000-4000-8000-000000000001"])).toEqual({
ok: true,
command: {
command: "server",
serverId: "00000000-0000-4000-8000-000000000001",
},
});
});
test("parses an experimental server session directory", () => {
expect(experimentalCli.parse(["server", "--session-dir", "~/pi-sessions"])).toEqual({
ok: true,
command: {
command: "server",
sessionDir: "~/pi-sessions",
},
});
});
test("leaves experimental-looking existing option values with the existing parser", () => {
expect(experimentalCli.parse(["--system-prompt", "--listen", "unix:///tmp/pi.sock"])).toMatchObject({
ok: true,
@ -139,6 +159,13 @@ describe("experimental CLI commands", () => {
"The experimental server command does not support existing CLI options yet",
],
[["client", "--connect", "ws://localhost:8080"], 'Unsupported --connect transport "ws:"'],
[["server", "--server-id", "not-a-uuid"], "Invalid --server-id"],
[["server", "--server-id"], "--server-id requires a value"],
[["server", "--session-dir"], "--session-dir requires a value"],
[
["server", "--session-dir", "/tmp/first", "--session-dir=/tmp/second"],
"--session-dir may only be specified once",
],
[["--listen"], "--listen requires a value"],
[["--connect="], "--connect is only valid for client mode"],
] as const)("rejects invalid experimental input %j", (argv, error) => {

View file

@ -80,6 +80,26 @@ describe("experimental CLI command composition", () => {
});
});
test("passes server options to the command action", async () => {
const runServer = vi.fn(() => undefined);
const result = await experimentalCli.execute(
["server", "--server-id", "00000000-0000-4000-8000-000000000001", "--session-dir", "./sessions"],
{
runPi: vi.fn(() => undefined),
runServer,
runClient: vi.fn(() => undefined),
},
);
const command = {
command: "server" as const,
serverId: "00000000-0000-4000-8000-000000000001",
sessionDir: "./sessions",
};
expect(result).toEqual({ ok: true, command });
expect(runServer).toHaveBeenCalledWith(command);
});
test.each(["pi", "server", "client"] as const)("executes the parsed %s command", async (name) => {
const context = {
runPi: vi.fn(() => undefined),

View file

@ -0,0 +1,49 @@
import type { ChildProcess } from "node:child_process";
import { once } from "node:events";
import { mkdtemp, rm } from "node:fs/promises";
import { createConnection } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import { spawnInternalProcess } from "../src/cli/experimental/internal-process-launcher.ts";
const children = new Set<ChildProcess>();
const directories = new Set<string>();
afterEach(async () => {
for (const child of children) {
if (child.exitCode === null && child.signalCode === null) {
const exited = once(child, "exit");
child.kill("SIGTERM");
await exited;
}
}
children.clear();
await Promise.all([...directories].map((directory) => rm(directory, { recursive: true, force: true })));
directories.clear();
});
describe.skipIf(process.platform === "win32")("experimental internal process launcher", () => {
test("starts the coordinator with the native runtime", async () => {
const directory = await mkdtemp(join(tmpdir(), "pi-internal-process-"));
directories.add(directory);
const publicPath = join(directory, "public.sock");
const controlPath = join(directory, "control.sock");
const child = spawnInternalProcess("coordinator", [publicPath, controlPath]);
children.add(child);
await expect.poll(() => canConnect(controlPath)).toBe(true);
expect(child.pid).not.toBe(process.pid);
});
});
function canConnect(path: string): Promise<boolean> {
return new Promise((resolve) => {
const socket = createConnection(path);
socket.once("connect", () => {
socket.destroy();
resolve(true);
});
socket.once("error", () => resolve(false));
});
}

View file

@ -1,20 +1,32 @@
import { chmod, lstat, mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import { chmod, lstat, mkdtemp, readdir, rm } from "node:fs/promises";
import { join, resolve } from "node:path";
import { PiClient } from "@earendil-works/pi-client";
import { createUnixTransportFactory } from "@earendil-works/pi-client/unix";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
type ExperimentalMemoryServer,
type ExperimentalServer,
runExperimentalClient,
startExperimentalMemoryServer,
startExperimentalServerGeneration,
startExperimentalCoordinatedServer,
startExperimentalServer,
} from "../src/cli/experimental/runtime.ts";
import { configureExperimentalWorkerModel, createExperimentalSessions } from "./experimental-session-support.ts";
const servers = new Set<ExperimentalMemoryServer>();
const servers = new Set<ExperimentalServer>();
const directories = new Set<string>();
let agentDir: string;
async function makeServer(): Promise<{ directory: string; runtime: ExperimentalMemoryServer }> {
beforeEach(async () => {
agentDir = await mkdtemp(join("/tmp", "pi-experimental-agent-"));
directories.add(agentDir);
await configureExperimentalWorkerModel(agentDir);
vi.stubEnv("PI_CODING_AGENT_DIR", agentDir);
await createExperimentalSessions(join(agentDir, "experimental", "sessions"), ["demo-1", "demo-2"]);
});
async function makeServer(): Promise<{ directory: string; runtime: ExperimentalServer }> {
const directory = await mkdtemp(join("/tmp", "pes-"));
directories.add(directory);
const runtime = await startExperimentalMemoryServer({ directory });
const runtime = await startExperimentalServer({ directory });
servers.add(runtime);
return { directory, runtime };
}
@ -22,17 +34,18 @@ async function makeServer(): Promise<{ directory: string; runtime: ExperimentalM
afterEach(async () => {
await Promise.all([...servers].map((server) => server.close()));
servers.clear();
vi.unstubAllEnvs();
await Promise.all([...directories].map((directory) => rm(directory, { recursive: true, force: true })));
directories.clear();
});
describe("experimental memory server composition", () => {
describe("experimental durable server composition", () => {
test("does not change permissions on an explicit socket-path parent", async () => {
const directory = await mkdtemp(join("/tmp", "pep-"));
directories.add(directory);
await chmod(directory, 0o750);
const serverId = "00000000-0000-4000-8000-000000000001";
const runtime = await startExperimentalMemoryServer({
const runtime = await startExperimentalServer({
path: join(directory, `${serverId}.sock`),
serverId,
});
@ -41,6 +54,81 @@ describe("experimental memory server composition", () => {
expect((await lstat(directory)).mode & 0o777).toBe(0o750);
});
test("resolves the configured session directory", async () => {
const directory = await mkdtemp(join("/tmp", "pes-"));
directories.add(directory);
const runtime = await startExperimentalServer({ directory, sessionDir: "relative/sessions" });
servers.add(runtime);
expect(runtime.sessionDir).toBe(resolve("relative/sessions"));
expect(runtime.workerPids.size).toBe(0);
});
test("uses PI_SERVER_DIR and PI_SERVER_ID", async () => {
const directory = await mkdtemp(join("/tmp", "pi-server-dir-"));
directories.add(directory);
const serverId = "00000000-0000-4000-8000-000000000001";
vi.stubEnv("PI_SERVER_DIR", directory);
vi.stubEnv("PI_SERVER_ID", serverId);
const runtime = await startExperimentalCoordinatedServer();
servers.add(runtime);
expect(runtime.serverId).toBe(serverId);
expect(runtime.socketPath).toBe(join(directory, `${serverId}.sock`));
expect((await lstat(directory)).mode & 0o777).toBe(0o700);
const publicSocket = await lstat(runtime.socketPath);
const controlSocket = await lstat(join(directory, `control-${runtime.serverId}.sock`));
expect(publicSocket.isSocket()).toBe(true);
expect(publicSocket.mode & 0o777).toBe(0o600);
expect(controlSocket.isSocket()).toBe(true);
expect(controlSocket.mode & 0o777).toBe(0o600);
const entries = await readdir(directory);
expect(entries).toHaveLength(3);
expect(entries.every((entry) => !entry.startsWith("."))).toBe(true);
expect(entries).toContain(`${serverId}.sock`);
expect(entries).toContain(`control-${serverId}.sock`);
expect(entries).toContainEqual(expect.stringMatching(new RegExp(`^server-${serverId}-[0-9a-f]{12}\\.sock$`)));
await expect(runExperimentalClient({ command: "client" })).resolves.toMatchObject({
kind: "list",
sessions: [{ sessionId: "demo-1" }, { sessionId: "demo-2" }],
});
});
test("runs and discovers multiple logical servers from one directory", async () => {
const directory = await mkdtemp(join("/tmp", "pi-multi-server-"));
directories.add(directory);
const firstId = "00000000-0000-4000-8000-000000000001";
const secondId = "00000000-0000-4000-8000-000000000002";
const [first, second] = await Promise.all([
startExperimentalCoordinatedServer({ directory, serverId: firstId }),
startExperimentalCoordinatedServer({ directory, serverId: secondId }),
]);
servers.add(first);
servers.add(second);
expect((await lstat(join(directory, `control-${firstId}.sock`))).isSocket()).toBe(true);
expect((await lstat(join(directory, `control-${secondId}.sock`))).isSocket()).toBe(true);
await expect(runExperimentalClient({ command: "client" }, { directory })).resolves.toEqual({
kind: "list",
sessions: [
{ serverId: firstId, sessionId: "demo-1" },
{ serverId: firstId, sessionId: "demo-2" },
{ serverId: secondId, sessionId: "demo-1" },
{ serverId: secondId, sessionId: "demo-2" },
],
});
await first.close();
await expect.poll(() => pathExists(first.socketPath)).toBe(false);
await expect(runExperimentalClient({ command: "client" }, { directory })).resolves.toEqual({
kind: "list",
sessions: [
{ serverId: secondId, sessionId: "demo-1" },
{ serverId: secondId, sessionId: "demo-2" },
],
});
});
test("discovers and lists seeded sessions without hosting either session", async () => {
const { directory, runtime } = await makeServer();
@ -109,66 +197,105 @@ describe("experimental memory server composition", () => {
for (const pid of pids) expect(processExists(pid)).toBe(false);
});
test("serializes concurrent launchers so only one generation remains active", async () => {
test("coordinated server replaces an exited worker on the next attach", async () => {
const directory = await mkdtemp(join("/tmp", "pew-"));
directories.add(directory);
const runtime = await startExperimentalCoordinatedServer({ directory });
servers.add(runtime);
await runExperimentalClient({ command: "client", sessionId: "demo-1" }, { directory });
const firstPid = runtime.workerPids.get("demo-1");
expect(firstPid).toEqual(expect.any(Number));
process.kill(firstPid!, "SIGKILL");
await expect.poll(() => runtime.workerPids.has("demo-1")).toBe(false);
await runExperimentalClient({ command: "client", sessionId: "demo-1" }, { directory });
const replacementPid = runtime.workerPids.get("demo-1");
expect(replacementPid).toEqual(expect.any(Number));
expect(replacementPid).not.toBe(firstPid);
});
test("normal coordinated shutdown stops detached workers", async () => {
const directory = await mkdtemp(join("/tmp", "pes-"));
directories.add(directory);
const runtime = await startExperimentalCoordinatedServer({ directory });
servers.add(runtime);
await runExperimentalClient({ command: "client", sessionId: "demo-1" }, { directory });
const pid = runtime.workerPids.get("demo-1");
expect(pid).toEqual(expect.any(Number));
await runtime.close();
expect(processExists(pid!)).toBe(false);
expect(runtime.workerPids.size).toBe(0);
await expect.poll(() => pathExists(runtime.socketPath)).toBe(false);
});
test("serializes concurrent launchers so only one server remains active", async () => {
const directory = await mkdtemp(join("/tmp", "pel-"));
directories.add(directory);
const generations = await Promise.all([
startExperimentalServerGeneration({ directory, socketDirectory: directory }),
startExperimentalServerGeneration({ directory, socketDirectory: directory }),
const runtimes = await Promise.all([
startExperimentalCoordinatedServer({ directory }),
startExperimentalCoordinatedServer({ directory }),
]);
for (const generation of generations) servers.add(generation);
for (const runtime of runtimes) servers.add(runtime);
expect(generations[0].serverId).toBe(generations[1].serverId);
const closed = await Promise.all(
generations.map((generation) =>
Promise.race([
generation.closed.then(() => true),
new Promise<false>((resolve) => setImmediate(() => resolve(false))),
]),
),
);
expect(closed.filter(Boolean)).toHaveLength(1);
expect(runtimes[0].serverId).toBe(runtimes[1].serverId);
await expect
.poll(async () => {
const closed = await Promise.all(
runtimes.map((runtime) =>
Promise.race([
runtime.closed.then(() => true),
new Promise<false>((resolve) => setImmediate(() => resolve(false))),
]),
),
);
return closed.filter(Boolean).length;
})
.toBe(1);
await expect(runExperimentalClient({ command: "client" }, { directory })).resolves.toMatchObject({
kind: "list",
sessions: [{ sessionId: "demo-1" }, { sessionId: "demo-2" }],
});
});
test("starts a clean replacement and requires explicit reattachment", async () => {
const firstDirectory = await mkdtemp(join("/tmp", "per-"));
const otherDirectory = await mkdtemp(join("/tmp", "per-"));
directories.add(firstDirectory);
directories.add(otherDirectory);
const first = await startExperimentalServerGeneration({
directory: firstDirectory,
socketDirectory: firstDirectory,
});
const other = await startExperimentalServerGeneration({
directory: otherDirectory,
socketDirectory: otherDirectory,
});
test("shuts down the replaced server without waiting for its clients", async () => {
const directory = await mkdtemp(join("/tmp", "peg-"));
directories.add(directory);
const first = await startExperimentalCoordinatedServer({ directory });
servers.add(first);
servers.add(other);
await runExperimentalClient({ command: "client", sessionId: "demo-1" }, { directory: firstDirectory });
await runExperimentalClient({ command: "client", sessionId: "demo-2" }, { directory: otherDirectory });
const firstWorkerPid = first.workerPids.get("demo-1");
const otherWorkerPid = other.workerPids.get("demo-2");
expect(firstWorkerPid).toEqual(expect.any(Number));
expect(otherWorkerPid).toEqual(expect.any(Number));
const replacement = await startExperimentalServerGeneration({
directory: firstDirectory,
socketDirectory: firstDirectory,
const client = await PiClient.connect({
serverId: first.serverId,
transportFactory: createUnixTransportFactory({ path: first.socketPath }),
});
await client.listSessions();
const replacement = await startExperimentalCoordinatedServer({ directory });
servers.add(replacement);
await first.closed;
await expect(runExperimentalClient({ command: "client" }, { directory })).resolves.toMatchObject({
kind: "list",
sessions: [{ sessionId: "demo-1" }, { sessionId: "demo-2" }],
});
await client.dispose();
});
test("discovers workers after replacing the server", async () => {
const firstDirectory = await mkdtemp(join("/tmp", "per-"));
directories.add(firstDirectory);
const first = await startExperimentalCoordinatedServer({ directory: firstDirectory });
servers.add(first);
await runExperimentalClient({ command: "client", sessionId: "demo-1" }, { directory: firstDirectory });
const firstWorkerPid = first.workerPids.get("demo-1");
expect(firstWorkerPid).toEqual(expect.any(Number));
const replacement = await startExperimentalCoordinatedServer({ directory: firstDirectory });
servers.add(replacement);
await first.closed;
expect(replacement.serverId).toBe(first.serverId);
expect(replacement.workerPids.size).toBe(0);
expect(first.workerPids.size).toBe(0);
expect(processExists(firstWorkerPid!)).toBe(false);
expect(other.workerPids.get("demo-2")).toBe(otherWorkerPid);
expect(processExists(otherWorkerPid!)).toBe(true);
expect(replacement.workerPids.get("demo-1")).toBe(firstWorkerPid);
await expect.poll(() => first.workerPids.size).toBe(0);
expect(processExists(firstWorkerPid!)).toBe(true);
await expect(runExperimentalClient({ command: "client" }, { directory: firstDirectory })).resolves.toMatchObject({
kind: "list",
@ -178,15 +305,42 @@ describe("experimental memory server composition", () => {
],
});
await runExperimentalClient({ command: "client", sessionId: "demo-1" }, { directory: firstDirectory });
expect(replacement.workerPids.get("demo-1")).toEqual(expect.any(Number));
expect(replacement.workerPids.get("demo-1")).not.toBe(firstWorkerPid);
expect(replacement.workerPids.get("demo-1")).toBe(firstWorkerPid);
await runExperimentalClient({ command: "client", sessionId: "demo-2" }, { directory: firstDirectory });
expect(replacement.workerPids.get("demo-2")).toEqual(expect.any(Number));
expect(replacement.workerPids.get("demo-2")).not.toBe(firstWorkerPid);
});
test("restores tracked sessions that are outside the replacement catalog", async () => {
const directory = await mkdtemp(join("/tmp", "pet-"));
const emptySessionDir = await mkdtemp(join("/tmp", "pet-sessions-"));
directories.add(directory);
directories.add(emptySessionDir);
const first = await startExperimentalCoordinatedServer({ directory });
servers.add(first);
await runExperimentalClient({ command: "client", sessionId: "demo-1" }, { directory });
const workerPid = first.workerPids.get("demo-1");
const replacement = await startExperimentalCoordinatedServer({
directory,
sessionDir: emptySessionDir,
});
servers.add(replacement);
await first.closed;
await expect(runExperimentalClient({ command: "client" }, { directory })).resolves.toEqual({
kind: "list",
sessions: [{ serverId: first.serverId, sessionId: "demo-1" }],
});
await runExperimentalClient({ command: "client", sessionId: "demo-1" }, { directory });
expect(replacement.workerPids.get("demo-1")).toBe(workerPid);
});
test("reports missing and ambiguous session selections", async () => {
const sharedDirectory = await mkdtemp(join("/tmp", "ped-"));
directories.add(sharedDirectory);
const firstShared = await startExperimentalMemoryServer({ directory: sharedDirectory });
const secondShared = await startExperimentalMemoryServer({ directory: sharedDirectory });
const firstShared = await startExperimentalServer({ directory: sharedDirectory });
const secondShared = await startExperimentalServer({ directory: sharedDirectory });
servers.add(firstShared);
servers.add(secondShared);
@ -197,8 +351,30 @@ describe("experimental memory server composition", () => {
runExperimentalClient({ command: "client", sessionId: "demo-1" }, { directory: sharedDirectory }),
).rejects.toThrow("Session demo-1 is available from more than one server");
});
test("rejects a duplicate session ID within one durable repository", async () => {
await createExperimentalSessions(
join(agentDir, "experimental", "sessions"),
["demo-1"],
join(agentDir, "other-cwd"),
);
const { directory } = await makeServer();
await expect(
runExperimentalClient({ command: "client", sessionId: "demo-1" }, { directory }),
).rejects.toMatchObject({ code: "session_ambiguous" });
});
});
async function pathExists(path: string): Promise<boolean> {
try {
await lstat(path);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
throw error;
}
}
function processExists(pid: number): boolean {
try {
process.kill(pid, 0);

View file

@ -1,10 +1,12 @@
import { access, mkdtemp, rm, writeFile } from "node:fs/promises";
import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import { acquireExperimentalServerProfile } from "../src/cli/experimental/server-profile.ts";
const directories = new Set<string>();
const FIRST_SERVER_ID = "00000000-0000-4000-8000-000000000001";
const SECOND_SERVER_ID = "00000000-0000-4000-8000-000000000002";
async function makeDirectory(): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), "psp-"));
@ -34,10 +36,26 @@ describe("experimental server profile", () => {
const second = await pendingSecond;
expect(secondAcquired).toBe(true);
expect(second.serverId).toBe(first.serverId);
expect((await readFile(join(directory, "default-server-id"), "utf8")).trim()).toBe(first.serverId);
await second.release();
});
test("does not serialize or share identity across server profiles", async () => {
test("does not serialize different server IDs in one directory", async () => {
const directory = await makeDirectory();
const [first, second] = await Promise.all([
acquireExperimentalServerProfile(directory, FIRST_SERVER_ID),
acquireExperimentalServerProfile(directory, SECOND_SERVER_ID),
]);
expect(first.serverId).toBe(FIRST_SERVER_ID);
expect(second.serverId).toBe(SECOND_SERVER_ID);
expect((await readdir(directory)).sort()).toEqual([
`launcher-${FIRST_SERVER_ID}.lock`,
`launcher-${SECOND_SERVER_ID}.lock`,
]);
await Promise.all([first.release(), second.release()]);
});
test("does not share the default identity across server directories", async () => {
const firstDirectory = await makeDirectory();
const secondDirectory = await makeDirectory();
@ -49,11 +67,19 @@ describe("experimental server profile", () => {
await Promise.all([first.release(), second.release()]);
});
test("rejects corrupt identity and releases the launcher lock", async () => {
test("rejects a corrupt default identity", async () => {
const directory = await makeDirectory();
await writeFile(join(directory, "server-id"), "invalid\n");
await writeFile(join(directory, "default-server-id"), "invalid\n");
await expect(acquireExperimentalServerProfile(directory)).rejects.toThrow(/Invalid experimental server identity/);
await expect(access(join(directory, ".launcher.lock"))).rejects.toMatchObject({ code: "ENOENT" });
await expect(acquireExperimentalServerProfile(directory)).rejects.toThrow(
/Invalid default experimental server identity/,
);
});
test("rejects an invalid explicit server ID", async () => {
const directory = await makeDirectory();
await expect(acquireExperimentalServerProfile(directory, "invalid")).rejects.toThrow(
"Invalid experimental server ID",
);
});
});

View file

@ -1,11 +1,11 @@
import { type ChildProcess, spawn } from "node:child_process";
import { once } from "node:events";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { lstat, mkdir, mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, test } from "vitest";
import { runExperimentalClient } from "../src/cli/experimental/runtime.ts";
import { configureExperimentalWorkerModel, createExperimentalSessions } from "./experimental-session-support.ts";
const cliPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
const processes = new Set<ChildProcess>();
@ -17,10 +17,16 @@ interface RunningCli {
readonly errors: () => string;
}
async function startServer(home: string): Promise<RunningCli> {
const child = spawn(process.execPath, ["--import", "tsx", cliPath, "server"], {
async function startServer(home: string, agentDir: string, serverDir: string): Promise<RunningCli> {
const child = spawn(process.execPath, [cliPath, "server"], {
cwd: fileURLToPath(new URL("../../..", import.meta.url)),
env: { ...process.env, HOME: home, PI_EXPERIMENTAL: "1" },
env: {
...process.env,
HOME: home,
PI_CODING_AGENT_DIR: agentDir,
PI_EXPERIMENTAL: "1",
PI_SERVER_DIR: serverDir,
},
stdio: ["ignore", "pipe", "pipe"],
});
processes.add(child);
@ -91,26 +97,30 @@ afterEach(async () => {
});
describe.skipIf(process.platform === "win32")("experimental CLI server replacement", () => {
test("a second CLI generation starts clean and accepts explicit reattachment", async () => {
const root = await mkdtemp(join(tmpdir(), "pcs-"));
test("a second CLI server takes over the stable coordinator socket", async () => {
const root = await mkdtemp(join("/tmp", "pcs-"));
directories.add(root);
const home = join(root, "long-home-segment".repeat(8));
await mkdir(home);
const first = await startServer(home);
const agentDir = join(root, "agent");
const serverDir = join(root, "server");
await configureExperimentalWorkerModel(agentDir);
await createExperimentalSessions(join(agentDir, "experimental", "sessions"), ["demo-1", "demo-2"]);
const first = await startServer(home, agentDir, serverDir);
const firstIdentity = await waitForOutput(
first,
/Server: ([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})/,
);
const firstSocket = await waitForOutput(first, /Socket: (.+\.sock)/);
expect(firstSocket[1]).not.toContain(home);
expect(Buffer.byteLength(firstSocket[1]!)).toBeLessThanOrEqual(103);
expect(firstSocket[1]).toBe(join(serverDir, `${firstIdentity[1]}.sock`));
expect((await lstat(join(serverDir, `control-${firstIdentity[1]}.sock`))).isSocket()).toBe(true);
await runExperimentalClient({
command: "client",
sessionId: "demo-1",
connect: { transport: "unix", path: firstSocket[1]! },
});
const replacement = await startServer(home);
const replacement = await startServer(home, agentDir, serverDir);
const replacementIdentity = await waitForOutput(
replacement,
/Server: ([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})/,

View file

@ -0,0 +1,24 @@
import { homedir } from "node:os";
import { resolve } from "node:path";
import { afterEach, describe, expect, test, vi } from "vitest";
import { resolveExperimentalSessionDirectory } from "../src/cli/experimental/runtime.ts";
afterEach(() => vi.unstubAllEnvs());
describe("experimental server session directory", () => {
test("uses the experimental directory under the configured agent directory by default", () => {
vi.stubEnv("PI_CODING_AGENT_DIR", "/tmp/pi-agent-config");
expect(resolveExperimentalSessionDirectory()).toBe("/tmp/pi-agent-config/experimental/sessions");
});
test("resolves an explicit relative directory from the current working directory", () => {
vi.stubEnv("PI_CODING_AGENT_DIR", "/tmp/pi-agent-config");
expect(resolveExperimentalSessionDirectory("relative/sessions")).toBe(resolve("relative/sessions"));
});
test("expands a tilde in an explicit directory", () => {
expect(resolveExperimentalSessionDirectory("~/custom-sessions")).toBe(resolve(homedir(), "custom-sessions"));
});
});

View file

@ -0,0 +1,32 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { type JsonlSessionMetadata, JsonlSessionRepo } from "@earendil-works/pi-agent-core";
import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
export async function createExperimentalSessions(
sessionsRoot: string,
ids: readonly string[],
cwd = process.cwd(),
): Promise<JsonlSessionMetadata[]> {
const fileSystem = new NodeExecutionEnv({ cwd });
const repo = new JsonlSessionRepo({ fileSystem, sessionsRoot });
const metadata: JsonlSessionMetadata[] = [];
try {
for (const id of ids) {
const session = await repo.create({ id, cwd });
metadata.push(session.metadata);
await session.close();
}
return metadata;
} finally {
await repo.close();
await fileSystem.cleanup();
}
}
export async function configureExperimentalWorkerModel(agentDir: string): Promise<void> {
await mkdir(agentDir, { recursive: true });
await writeFile(join(agentDir, "auth.json"), JSON.stringify({ anthropic: { type: "api_key", key: "test-key" } }), {
mode: 0o600,
});
}

View file

@ -1,34 +1,86 @@
import { afterEach, describe, expect, test } from "vitest";
import { mkdtemp, readdir, rm } from "node:fs/promises";
import { join } from "node:path";
import { type JsonlSessionMetadata, JsonlSessionRepo } from "@earendil-works/pi-agent-core";
import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
type ExperimentalSessionWorker,
startExperimentalSessionWorker,
} from "../src/cli/experimental/session-worker.ts";
import { configureExperimentalWorkerModel, createExperimentalSessions } from "./experimental-session-support.ts";
const workers = new Set<ExperimentalSessionWorker>();
const fixtureUrl = new URL("fixtures/session-worker-fixture.ts", import.meta.url);
const sessionDir = "/tmp/pi-session-worker-tests";
const directories = new Set<string>();
let serverDir: string;
beforeEach(async () => {
serverDir = await mkdtemp(join("/tmp", "pi-session-worker-server-"));
directories.add(serverDir);
vi.stubEnv("PI_SERVER_DIR", serverDir);
});
function testMetadata(id: string): JsonlSessionMetadata {
return {
id,
createdAt: 1,
storageVersion: 1,
cwd: "/tmp",
path: `/tmp/${id}.jsonl`,
modifiedAt: 1,
};
}
afterEach(async () => {
await Promise.all([...workers].map((worker) => worker.close()));
workers.clear();
vi.unstubAllEnvs();
await Promise.all([...directories].map((directory) => rm(directory, { recursive: true, force: true })));
directories.clear();
});
describe("experimental session worker controller", () => {
test("starts a real child, validates readiness, and closes idempotently", async () => {
const worker = await startExperimentalSessionWorker("ready", { workerUrl: fixtureUrl });
const worker = await startExperimentalSessionWorker(testMetadata("ready"), {
sessionDir,
workerUrl: fixtureUrl,
});
workers.add(worker);
expect(worker.sessionId).toBe("ready");
expect(worker.pid).not.toBe(process.pid);
expect(processExists(worker.pid)).toBe(true);
const entries = await readdir(serverDir);
expect(entries).toHaveLength(1);
expect(entries[0]).toMatch(/^worker-[0-9a-f-]+\.sock$/);
await Promise.all([worker.close(), worker.close()]);
expect(processExists(worker.pid)).toBe(false);
await expect(worker.terminated).resolves.toBeUndefined();
});
test("passes session IDs containing null bytes through serialized metadata", async () => {
const worker = await startExperimentalSessionWorker(testMetadata("ready\0"), {
sessionDir,
workerUrl: fixtureUrl,
});
workers.add(worker);
expect(worker.sessionId).toBe("ready\0");
await worker.close();
});
test("rejects a relative session directory before starting a child", async () => {
await expect(
startExperimentalSessionWorker(testMetadata("ready"), { sessionDir: "relative", workerUrl: fixtureUrl }),
).rejects.toThrow("sessionDir must be absolute");
});
test("times out and kills a child that never reports readiness", async () => {
await expect(
startExperimentalSessionWorker("startup-hang", {
startExperimentalSessionWorker(testMetadata("startup-hang"), {
sessionDir,
workerUrl: fixtureUrl,
startupTimeoutMs: 10,
}),
@ -40,11 +92,14 @@ describe("experimental session worker controller", () => {
["exit before readiness", "exit", /exited before readiness/],
["invalid ready identity", "mismatch", /invalid identity/],
] as const)("rejects %s", async (_label, sessionId, message) => {
await expect(startExperimentalSessionWorker(sessionId, { workerUrl: fixtureUrl })).rejects.toThrow(message);
await expect(
startExperimentalSessionWorker(testMetadata(sessionId), { sessionDir, workerUrl: fixtureUrl }),
).rejects.toThrow(message);
});
test("kills a worker that ignores graceful shutdown", async () => {
const worker = await startExperimentalSessionWorker("hang", {
const worker = await startExperimentalSessionWorker(testMetadata("hang"), {
sessionDir,
workerUrl: fixtureUrl,
shutdownTimeoutMs: 10,
});
@ -55,7 +110,10 @@ describe("experimental session worker controller", () => {
});
test("reports an unexpected exit after readiness", async () => {
const worker = await startExperimentalSessionWorker("ready", { workerUrl: fixtureUrl });
const worker = await startExperimentalSessionWorker(testMetadata("ready"), {
sessionDir,
workerUrl: fixtureUrl,
});
workers.add(worker);
process.kill(worker.pid, "SIGKILL");
@ -64,6 +122,53 @@ describe("experimental session worker controller", () => {
);
expect(processExists(worker.pid)).toBe(false);
});
test("fails clearly when no model is configured", async () => {
const root = await mkdtemp(join("/tmp", "pi-session-worker-no-model-"));
directories.add(root);
const durableSessionDir = join(root, "sessions");
const [metadata] = await createExperimentalSessions(durableSessionDir, ["no-model"]);
const agentDir = join(root, "agent");
vi.stubEnv("PI_CODING_AGENT_DIR", agentDir);
await expect(startExperimentalSessionWorker(metadata!, { sessionDir: durableSessionDir })).rejects.toThrow(
"could not find a configured model",
);
});
test("opens a durable session and creates a real Harness before readiness", async () => {
const root = await mkdtemp(join("/tmp", "pi-session-worker-real-"));
directories.add(root);
const durableSessionDir = join(root, "sessions");
const [metadata] = await createExperimentalSessions(durableSessionDir, ["durable-session"]);
const agentDir = join(root, "agent");
await configureExperimentalWorkerModel(agentDir);
vi.stubEnv("PI_CODING_AGENT_DIR", agentDir);
const worker = await startExperimentalSessionWorker(metadata!, { sessionDir: durableSessionDir });
workers.add(worker);
expect(worker.sessionId).toBe("durable-session");
expect(worker.pid).not.toBe(process.pid);
expect(processExists(worker.pid)).toBe(true);
await worker.close();
await expect(worker.terminated).resolves.toBeUndefined();
const executionEnv = new NodeExecutionEnv({ cwd: metadata!.cwd });
const repo = new JsonlSessionRepo({ fileSystem: executionEnv, sessionsRoot: durableSessionDir });
const reopened = await repo.open(metadata!);
await expect(reopened.getRegister("lane.config", "main")).resolves.toMatchObject({
value: {
model: { provider: "anthropic" },
thinkingLevel: "off",
activeToolNames: [],
},
});
await reopened.close();
await repo.close();
await executionEnv.cleanup();
});
});
function processExists(pid: number): boolean {

View file

@ -1,23 +1,52 @@
import { createConnection } from "node:net";
// Test-only child executable. Keep this independent from the real worker so
// lifecycle failure paths remain deterministic as production integration evolves.
type Command = { type: "shutdown" };
const address = process.env.PI_SESSION_WORKER_CONTROL_ADDRESS;
const token = process.env.PI_SESSION_WORKER_CONTROL_TOKEN;
const encodedSessionKey = process.env.PI_SESSION_WORKER_SESSION_KEY_BASE64;
if (!address || !token || !encodedSessionKey) throw new Error("Fixture requires worker control configuration");
const sessionKey = Buffer.from(encodedSessionKey, "base64url").toString();
const mode = process.argv[2];
const parsedMetadata: unknown = JSON.parse(process.argv[3] ?? "null");
const sessionId =
typeof parsedMetadata === "object" && parsedMetadata !== null && "id" in parsedMetadata
? parsedMetadata.id
: undefined;
const mode = typeof sessionId === "string" ? sessionId.replaceAll("\0", "") : undefined;
const socket = createConnection(address);
await new Promise<void>((resolve, reject) => {
socket.once("connect", resolve);
socket.once("error", reject);
});
const send = (message: unknown): void => {
socket.write(`${JSON.stringify(message)}\n`);
};
send({ type: "register_worker", protocol: 1, token, sessionKey, sessionId, pid: process.pid });
if (mode === "ready") {
process.send?.({ type: "ready", sessionId: mode, pid: process.pid });
process.on("message", (message: Command) => {
if (message?.type === "shutdown") process.exit(0);
send({ type: "worker_ready", sessionId, pid: process.pid });
let buffered = "";
socket.setEncoding("utf8");
socket.on("data", (chunk: string) => {
buffered += chunk;
if (buffered.includes("\n") && JSON.parse(buffered.slice(0, buffered.indexOf("\n"))).type === "shutdown") {
process.exit(0);
}
});
} else if (mode === "fail") {
process.send?.({ type: "failed", message: "fixture startup failed" });
send({ type: "worker_failed", message: "fixture startup failed" });
} else if (mode === "exit") {
process.exit(2);
} else if (mode === "startup-hang") {
process.on("message", () => {});
socket.resume();
} else if (mode === "hang") {
process.send?.({ type: "ready", sessionId: mode, pid: process.pid });
process.on("message", () => {});
send({ type: "worker_ready", sessionId, pid: process.pid });
socket.resume();
} else {
process.send?.({ type: "ready", sessionId: mode === undefined ? "missing" : `${mode}-different`, pid: process.pid });
send({
type: "worker_ready",
sessionId: sessionId === undefined ? "missing" : `${sessionId}-different`,
pid: process.pid,
});
}

View file

@ -8,7 +8,6 @@
"@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/control": ["../client/dist/control.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"],

View file

@ -6,14 +6,11 @@ Protocol version `1` currently contains the first control-plane slice:
- a version handshake that identifies the logical `serverId`;
- a service RPC manifest with `list()` and `attach(sessionId)`;
- a separate launcher-only server-control manifest with `drain()`;
- correlated responses and bounded protocol errors.
The manifest generates typed client methods and validated server dispatch. The wire uses generic `{ serverId, method, args }` calls rather than a hand-written command union. `list()` returns the durable `SessionMetadata` values from `SessionRepo.list()`. `attach()` returns only the attached `sessionId`; the real `Session` and `AgentHarness` remain hosted by the server.
`drain()` tells one server generation to close its hosted Harnesses, acknowledges successful cleanup, and begins shutdown. It does not transfer hosted-session state. The server does not know whether another generation will replace it; replacement and explicit client reattachment belong to the launcher and clients.
Separating `ServerControlRpc` from `ServiceRpc` is an API boundary, not an authorization boundary. Transports exposing server control must authenticate the caller as an administrator. The experimental local launcher uses a mode-`0600` Unix socket and trusts same-user peers.
Server and worker lifecycle is intentionally outside this public protocol. The experimental local coordinator is only an opaque message router; each replaceable server process owns the private lifecycle protocol.
Each wire frame consists of a four-byte unsigned big-endian payload length followed by one definite-length CBOR item. `encodeClientMessage()` and `encodeServerMessage()` validate and encode complete frames. `ClientMessageDecoder` and `ServerMessageDecoder` accept arbitrary stream fragmentation and coalescing.

View file

@ -63,36 +63,16 @@ export type ServiceRpcResultUnion = RpcResultUnion<ServiceRpcManifest>;
export const ServiceRpcCallSchema = Type.Unsafe<ServiceRpcCall>(createRpcCallSchema(ServiceRpc));
export const ServiceRpcResultSchema = Type.Unsafe<ServiceRpcResultUnion>(createRpcResultSchema(ServiceRpc));
/** Administrative lifecycle operations reserved for the server launcher. */
export const ServerControlRpc = defineRpc({
drain: {
args: Type.Tuple([]),
result: StrictObject({}),
},
});
export type ServerControlRpcManifest = typeof ServerControlRpc;
export type ServerControlRpcMethod = RpcMethodName<ServerControlRpcManifest>;
export type ServerControlRpcArgs<TMethod extends ServerControlRpcMethod> = RpcArgs<ServerControlRpcManifest, TMethod>;
export type ServerControlRpcResult<TMethod extends ServerControlRpcMethod> = RpcResult<
ServerControlRpcManifest,
TMethod
>;
export type ServerControlRpcCall = RpcCall<ServerControlRpcManifest>;
export type ServerControlRpcResultUnion = RpcResultUnion<ServerControlRpcManifest>;
export const ServerControlRpcCallSchema = Type.Unsafe<ServerControlRpcCall>(createRpcCallSchema(ServerControlRpc));
export const ServerControlRpcResultSchema = Type.Unsafe<ServerControlRpcResultUnion>(
createRpcResultSchema(ServerControlRpc),
);
export type ProtocolRpcCall = ServiceRpcCall | ServerControlRpcCall;
export type ProtocolRpcResult = ServiceRpcResultUnion | ServerControlRpcResultUnion;
const ProtocolRpcCallSchema = Type.Union([ServiceRpcCallSchema, ServerControlRpcCallSchema]);
const ProtocolRpcResultSchema = Type.Union([ServiceRpcResultSchema, ServerControlRpcResultSchema]);
export type ProtocolRpcCall = ServiceRpcCall;
export type ProtocolRpcResult = ServiceRpcResultUnion;
const ProtocolRpcCallSchema = ServiceRpcCallSchema;
const ProtocolRpcResultSchema = ServiceRpcResultSchema;
export const ProtocolErrorCodeSchema = Type.Union([
Type.Literal("version"),
Type.Literal("wrong_server"),
Type.Literal("session_not_found"),
Type.Literal("session_ambiguous"),
Type.Literal("server_draining"),
Type.Literal("invalid_request"),
Type.Literal("internal_error"),

View file

@ -17,7 +17,6 @@ import {
ProtocolValidationError,
parseClientMessage,
parseServerMessage,
ServerControlRpc,
type ServerHello,
type ServerMessage,
ServerMessageDecoder,
@ -79,22 +78,6 @@ describe("RPC manifest", () => {
await expect(dispatch({ method: "list", args: [] }, undefined)).rejects.toThrow(/Invalid result.*list/);
});
test("keeps launcher control methods separate from session operations", async () => {
const calls: unknown[] = [];
const control = createRpcClient(ServerControlRpc, async (call) => {
calls.push(call);
return {};
});
const dispatch = createRpcDispatcher(ServerControlRpc, {
drain: () => ({}),
});
await expect(control.drain()).resolves.toEqual({});
await expect(dispatch({ method: "drain", args: [] }, undefined)).resolves.toEqual({});
expect(calls).toEqual([{ method: "drain", args: [] }]);
expect("drain" in createRpcClient(ServiceRpc, async () => [])).toBe(false);
});
test("rejects empty manifests instead of creating unusable RPC clients", () => {
expect(() => defineRpc({})).toThrow(/at least one method/);
});
@ -138,7 +121,7 @@ describe("protocol validation", () => {
).toThrow(ProtocolValidationError);
});
test("validates service and launcher-control RPC calls with logical targets", () => {
test("validates service RPC calls with logical targets", () => {
const list: ClientMessage = {
type: "request",
id: "request-1",
@ -151,15 +134,8 @@ describe("protocol validation", () => {
serverId: "00000000-0000-4000-8000-000000000001",
call: { method: "attach", args: ["session-1"] },
};
const drain: ClientMessage = {
type: "request",
id: "request-3",
serverId: "00000000-0000-4000-8000-000000000001",
call: { method: "drain", args: [] },
};
expect(parseClientMessage(list)).toEqual(list);
expect(parseClientMessage(attach)).toEqual(attach);
expect(parseClientMessage(drain)).toEqual(drain);
expect(() => parseClientMessage({ ...attach, call: { method: "attach", args: [] } })).toThrow(
ProtocolValidationError,
);
@ -191,17 +167,6 @@ describe("protocol validation", () => {
expect(parseServerMessage(message)).toEqual(message);
});
test("validates drain acknowledgements", () => {
const message: ServerMessage = {
type: "response",
id: "request-1",
ok: true,
result: {},
};
expect(parseServerMessage(message)).toEqual(message);
expect(() => parseServerMessage({ ...message, result: { sessionIds: [] } })).toThrow(ProtocolValidationError);
});
test.each([
[
"empty request id",

View file

@ -5,36 +5,57 @@ Experimental local server for the new durable Session and Agent Harness interfac
The current slice supports two client operations:
- `list` calls `SessionRepo.list()` without opening sessions.
- `attach` finds the requested metadata, calls `SessionRepo.open()`, creates an `AgentHarness`, and retains that Harness in the server.
The separate launcher-control operation `drain` closes hosted Harnesses, acknowledges successful cleanup, and closes that server generation. It does not transfer hosted-session state, and the server does not decide whether a replacement should start. Clients explicitly reconnect and reattach after replacement.
- `attach` finds the requested metadata, passes it to the host, and retains the returned Harness handle in the server.
Concurrent attachments to one session reuse one hosted Harness. Attachment is a one-shot acquisition request, so losing the client connection does not close the Harness. Server shutdown closes every hosted Harness, releasing its Session writer ownership.
```ts
import { randomUUID } from "node:crypto";
import { MemorySessionRepo } from "@earendil-works/pi-agent-core";
import type { PiServerHost } from "@earendil-works/pi-server";
import { MemorySessionRepo, type Session } from "@earendil-works/pi-agent-core";
import {
type HostedHarnessHandle,
type PiServerHost,
} from "@earendil-works/pi-server";
import { createUnixServer, getUnixSocketPath } from "@earendil-works/pi-server/unix";
const sessions = new MemorySessionRepo();
const host: PiServerHost = {
sessions,
async createHarness(session) {
return createApplicationHarness({ session });
},
};
async function startServer(
createHarnessForSession: (session: Session) => Promise<HostedHarnessHandle>,
) {
const sessions = new MemorySessionRepo();
const host: PiServerHost = {
sessions,
async createHarness(metadata) {
const session = await sessions.open(metadata);
try {
return await createHarnessForSession(session);
} catch (error) {
try {
await session.close();
} catch (cleanupError) {
throw new AggregateError(
[error, cleanupError],
"Harness creation and Session cleanup failed",
);
}
throw error;
}
},
};
const serverId = randomUUID();
const server = createUnixServer(host, {
serverId,
path: getUnixSocketPath(serverId, "/run/user/1000/pi"),
});
await server.start();
const serverId = randomUUID();
const server = createUnixServer(host, {
serverId,
path: getUnixSocketPath(serverId, "/run/user/1000/pi"),
});
await server.start();
return server;
}
```
Applications supply the repository and Harness factory. `serverId` is a logical identity supplied by the launcher, not a socket address. The Unix preset requires an explicit physical `path`; `getUnixSocketPath()` derives one from a caller-selected directory. Choose a short, private runtime directory rather than deriving the route from an unbounded home-directory path. A long-lived launcher can reuse the same ID and path when replacing a server process.
Applications supply a session catalog and a Harness factory. The server only calls `list()`; the host receives the repository's concrete metadata and owns opening the Session, creating the Harness, and cleaning up failed creation. This permits the host to perform those operations in a worker process without passing an open JavaScript Session across processes.
`serverId` is a logical identity supplied by the launcher, not a socket address. The Unix preset requires an explicit physical `path`; `getUnixSocketPath()` derives one from a caller-selected directory. Choose a short, private runtime directory rather than deriving the route from an unbounded home-directory path. A long-lived launcher can reuse the same ID and path when replacing a server process.
`PiServer` composes authenticated transports through `PiServerListener`. The Unix submodule provides `createUnixListener()` and `createUnixServer()`. Low-level CBOR framing and validation come from `@earendil-works/pi-protocol`.
`ServerControlRpc` is administrative but is not itself an authorization mechanism. A transport exposing it must trust every connected peer as an administrator or authenticate control access separately. The experimental local launcher enforces mode `0600` on its Unix socket and therefore trusts same-user peers.
Server and worker lifecycle is managed by the replaceable application server rather than the public Pi protocol. The experimental coordinator only supplies stable routing between processes.

View file

@ -1,6 +1,9 @@
import type { ProtocolErrorCode } from "@earendil-works/pi-protocol";
type PiServerOperationErrorCode = Extract<ProtocolErrorCode, "wrong_server" | "session_not_found" | "server_draining">;
type PiServerOperationErrorCode = Extract<
ProtocolErrorCode,
"wrong_server" | "session_not_found" | "session_ambiguous" | "server_draining"
>;
export const INTERNAL_SERVER_ERROR_MESSAGE = "Internal server error";
@ -29,6 +32,13 @@ export class SessionNotFoundError extends PiServerError {
}
}
export class SessionAmbiguousError extends PiServerError {
constructor() {
super("session_ambiguous", "Session ID matches more than one session");
this.name = "SessionAmbiguousError";
}
}
export class ServerDrainingError extends PiServerError {
constructor() {
super("server_draining", "Server is draining");

View file

@ -1,39 +1,54 @@
import type { SessionMetadata } from "@earendil-works/pi-agent-core";
import {
createRpcDispatcher,
type SessionMetadata as ProtocolSessionMetadata,
ProtocolValidationError,
ServiceRpc,
type ServiceRpcCall,
type ServiceRpcResultUnion,
} from "@earendil-works/pi-protocol";
import { ServerDrainingError, SessionNotFoundError } from "./errors.ts";
import { ServerDrainingError, SessionAmbiguousError, SessionNotFoundError } from "./errors.ts";
import type { HostedHarnessHandle, PiServerHost } from "./types.ts";
class HarnessCleanupError extends AggregateError {}
function toProtocolSessionMetadata(metadata: SessionMetadata): ProtocolSessionMetadata {
return {
id: metadata.id,
createdAt: metadata.createdAt,
storageVersion: metadata.storageVersion,
...(metadata.cwd === undefined ? {} : { cwd: metadata.cwd }),
...(metadata.parentSessionId === undefined ? {} : { parentSessionId: metadata.parentSessionId }),
...(metadata.legacyParentSessionPath === undefined
? {}
: { legacyParentSessionPath: metadata.legacyParentSessionPath }),
};
}
interface HostedSession {
readonly id: string;
readonly harness: HostedHarnessHandle;
}
interface HostedHarnessManagerOptions {
host: PiServerHost;
interface HostedHarnessManagerOptions<TMetadata extends SessionMetadata> {
host: PiServerHost<TMetadata>;
isClosing: () => boolean;
reportError: (error: unknown) => void;
}
export class HostedHarnessManager {
private readonly options: HostedHarnessManagerOptions;
export class HostedHarnessManager<TMetadata extends SessionMetadata = SessionMetadata> {
private readonly options: HostedHarnessManagerOptions<TMetadata>;
private readonly hostedSessions = new Map<string, HostedSession>();
private readonly openingSessions = new Map<string, Promise<HostedSession>>();
private closePromise?: Promise<void>;
private readonly dispatchRpc: (call: ServiceRpcCall, context: undefined) => Promise<ServiceRpcResultUnion>;
constructor(options: HostedHarnessManagerOptions) {
constructor(options: HostedHarnessManagerOptions<TMetadata>) {
this.options = options;
this.dispatchRpc = createRpcDispatcher(
ServiceRpc,
{
list: () => this.options.host.sessions.list(),
list: async () => (await this.options.host.sessions.list()).map(toProtocolSessionMetadata),
attach: async (_context, sessionId) => {
if (this.options.isClosing()) throw new ServerDrainingError();
const hosted = await this.acquire(sessionId);
@ -91,21 +106,11 @@ export class HostedHarnessManager {
}
private async open(sessionId: string): Promise<HostedSession> {
const metadata = (await this.options.host.sessions.list()).find((candidate) => candidate.id === sessionId);
if (!metadata) throw new SessionNotFoundError(`Unknown session: ${sessionId}`);
const session = await this.options.host.sessions.open(metadata);
let harness: HostedHarnessHandle;
try {
harness = await this.options.host.createHarness(session);
} catch (error) {
try {
await session.close();
} catch (closeError) {
this.options.reportError(closeError);
throw new HarnessCleanupError([error, closeError], "Harness creation and Session cleanup failed");
}
throw error;
}
const matches = (await this.options.host.sessions.list()).filter((candidate) => candidate.id === sessionId);
if (matches.length === 0) throw new SessionNotFoundError(`Unknown session: ${sessionId}`);
if (matches.length > 1) throw new SessionAmbiguousError();
const metadata = matches[0]!;
const harness = await this.options.host.createHarness(metadata);
if (this.options.isClosing()) {
try {
await harness.close();

View file

@ -1,3 +1,4 @@
import type { SessionMetadata } from "@earendil-works/pi-agent-core";
import {
type ClientHello,
type ClientMessage,
@ -12,7 +13,6 @@ import {
ProtocolValidationError,
type RequestEnvelope,
type ResponseEnvelope,
type ServerControlRpcResult,
type ServerHello,
type ServerHelloError,
type ServerMessage,
@ -23,7 +23,7 @@ import {
type ConnectionState,
isTerminalConnection,
} from "./connection.ts";
import { INTERNAL_SERVER_ERROR_MESSAGE, PiServerError, ServerDrainingError, WrongServerError } from "./errors.ts";
import { INTERNAL_SERVER_ERROR_MESSAGE, PiServerError, WrongServerError } from "./errors.ts";
import { HostedHarnessManager } from "./hosted-harness-manager.ts";
import type { PiServerListener } from "./listener.ts";
import type { PiServerHost, PiServerOptions } from "./types.ts";
@ -32,7 +32,7 @@ const DEFAULT_HANDSHAKE_TIMEOUT_MS = 5_000;
const MAX_UINT32 = 0xffff_ffff;
const MAX_TIMER_DELAY_MS = 2_147_483_647;
export class PiServer {
export class PiServer<TMetadata extends SessionMetadata = SessionMetadata> {
readonly serverId: string;
/** Resolves after shutdown, or rejects when listener or hosted-Harness cleanup fails. */
readonly closed: Promise<void>;
@ -42,7 +42,7 @@ export class PiServer {
private readonly handshakeTimeoutMs: number;
private readonly onError: ((error: Error) => void) | undefined;
private readonly connections = new Set<ConnectionState>();
private readonly sessions: HostedHarnessManager;
private readonly sessions: HostedHarnessManager<TMetadata>;
private closing = false;
private closePromise?: Promise<void>;
private closedSettled = false;
@ -51,7 +51,7 @@ export class PiServer {
private startPromise?: Promise<this>;
private started = false;
constructor(host: PiServerHost, options: PiServerOptions) {
constructor(host: PiServerHost<TMetadata>, options: PiServerOptions) {
const resolved = resolveOptions(options);
this.listeners = options.listeners;
this.serverId = options.serverId;
@ -253,19 +253,9 @@ export class PiServer {
}
private async handleRequest(state: ConnectionState, envelope: RequestEnvelope): Promise<void> {
const draining = envelope.call.method === "drain";
let ownsDrain = false;
try {
if (envelope.serverId !== this.serverId) throw new WrongServerError();
let result: ProtocolRpcResult;
if (draining) {
if (this.closing) throw new ServerDrainingError();
this.closing = true;
ownsDrain = true;
result = await this.drain();
} else {
result = await this.sessions.executeCall(envelope.call);
}
const result: ProtocolRpcResult = await this.sessions.executeCall(envelope.call);
await this.sendMessage(state, {
type: "response",
id: envelope.id,
@ -279,20 +269,9 @@ export class PiServer {
ok: false,
error: this.toProtocolError(error),
} satisfies ResponseEnvelope);
} finally {
if (ownsDrain) this.scheduleClose();
}
}
private async drain(): Promise<ServerControlRpcResult<"drain">> {
await this.sessions.close();
return {};
}
private scheduleClose(): void {
void this.close().catch((error: unknown) => this.reportError(error));
}
private transportClosed(connection: ConnectionState): void {
if (!connection.disconnected && connection.stage !== "closing") {
try {

View file

@ -73,9 +73,9 @@ interface ListDelay {
export class TestServerHost implements PiServerHost {
readonly repo = new MemorySessionRepo({ now: () => 1 });
readonly harnesses = new Map<string, TestHarness[]>();
openCount = 0;
failNextOpen?: Error;
failNextHarness?: Error;
createHarnessCount = 0;
nextCreateHarnessError?: Error;
nextHarnessCloseError?: Error;
readonly sessions: PiServerHost["sessions"] = {
list: async () => {
const delay = this.nextListDelay;
@ -86,36 +86,38 @@ export class TestServerHost implements PiServerHost {
}
return this.repo.list();
},
open: async (metadata) => {
this.openCount += 1;
const gate = this.nextOpenGate;
if (gate) {
this.nextOpenGate = undefined;
gate.entered.resolve(undefined);
await gate.release.promise;
}
if (this.failNextOpen) {
const error = this.failNextOpen;
this.failNextOpen = undefined;
throw error;
}
return this.repo.open(metadata);
},
};
private nextListDelay?: ListDelay;
private nextOpenGate?: OpenGate;
private nextCreateHarnessGate?: OpenGate;
async createHarness(session: Session): Promise<HostedHarnessHandle> {
if (this.failNextHarness) {
const error = this.failNextHarness;
this.failNextHarness = undefined;
async createHarness(metadata: SessionMetadata): Promise<HostedHarnessHandle> {
this.createHarnessCount += 1;
const gate = this.nextCreateHarnessGate;
if (gate) {
this.nextCreateHarnessGate = undefined;
gate.entered.resolve(undefined);
await gate.release.promise;
}
const session = await this.repo.open(metadata);
try {
if (this.nextCreateHarnessError) {
const error = this.nextCreateHarnessError;
this.nextCreateHarnessError = undefined;
throw error;
}
const harness = new TestHarness(session);
if (this.nextHarnessCloseError) {
harness.failClose = this.nextHarnessCloseError;
this.nextHarnessCloseError = undefined;
}
const harnesses = this.harnesses.get(metadata.id) ?? [];
harnesses.push(harness);
this.harnesses.set(metadata.id, harnesses);
return harness;
} catch (error) {
await session.close();
throw error;
}
const harness = new TestHarness(session);
const harnesses = this.harnesses.get(session.metadata.id) ?? [];
harnesses.push(harness);
this.harnesses.set(session.metadata.id, harnesses);
return harness;
}
async seed(id = "session-1", parentSessionId?: string): Promise<SessionMetadata> {
@ -131,9 +133,9 @@ export class TestServerHost implements PiServerHost {
return delay;
}
gateNextOpen(): OpenGate {
gateNextCreateHarness(): OpenGate {
const gate = { entered: new Deferred<void>(), release: new Deferred<void>() };
this.nextOpenGate = gate;
this.nextCreateHarnessGate = gate;
return gate;
}

View file

@ -85,6 +85,8 @@ class UnixListener implements PiServerListener {
this.socketIdentity = { dev: stats.dev, ino: stats.ino };
await link(ownedBindPath, this.path);
await setSocketMode(this.path, this.mode);
await removePath(ownedBindPath);
this.ownedBindPath = undefined;
} catch (error) {
await this.closeServerAndCleanup(server);
this.server = undefined;
@ -144,8 +146,7 @@ class UnixListener implements PiServerListener {
try {
await closeNetServer(server, (error) => this.reportError(error));
} finally {
// Remove the private bind path before the public route. Launchers use the
// public route disappearing as the signal that a successor may bind safely.
// Remove an unpublished startup bind path before the public route.
if (this.ownedBindPath) await removePath(this.ownedBindPath);
this.ownedBindPath = undefined;
await this.cleanupOwnedSocket();
@ -165,7 +166,7 @@ class UnixListener implements PiServerListener {
}
if (!current.isSocket() || current.dev !== identity.dev || current.ino !== identity.ino) return;
const preserved = join(dirname(this.path), `.c-${randomUUID().slice(0, 6)}`);
const preserved = join(dirname(this.path), `cleanup-${randomUUID().slice(0, 6)}`);
try {
await rename(this.path, preserved);
} catch (error) {
@ -301,7 +302,7 @@ export class UnixByteConnection implements ByteConnection {
function getOwnedBindPath(path: string): string {
const suffix = createHash("sha256").update(path).digest("hex").slice(0, 8);
return join(dirname(path), `.p-${suffix}`);
return join(dirname(path), `bind-${suffix}`);
}
async function removeStaleSocket(path: string): Promise<void> {
@ -315,7 +316,7 @@ async function removeStaleSocket(path: string): Promise<void> {
if (!original.isSocket()) throw new Error(`Refusing to remove non-socket Unix listener path: ${path}`);
if (await isSocketLive(path)) throw new Error(`Unix listener is already running: ${path}`);
const preserved = join(dirname(path), `.s-${randomUUID().slice(0, 6)}`);
const preserved = join(dirname(path), `stale-${randomUUID().slice(0, 6)}`);
try {
await rename(path, preserved);
} catch (error) {

View file

@ -1,10 +1,14 @@
import type { SessionMetadata } from "@earendil-works/pi-agent-core";
import { PiServer } from "../../server.ts";
import type { PiServerHost } from "../../types.ts";
import { createUnixListener } from "./listener.ts";
import type { UnixServerOptions } from "./types.ts";
/** Compose PiServer with one Unix-domain socket listener. */
export function createUnixServer(host: PiServerHost, options: UnixServerOptions): PiServer {
export function createUnixServer<TMetadata extends SessionMetadata>(
host: PiServerHost<TMetadata>,
options: UnixServerOptions,
): PiServer<TMetadata> {
const listener = createUnixListener({
path: options.path,
mode: options.mode,

View file

@ -1,4 +1,4 @@
import type { AgentHarness, Session, SessionRepo } from "@earendil-works/pi-agent-core";
import type { AgentHarness, SessionMetadata } from "@earendil-works/pi-agent-core";
import type { PiServerListener } from "./listener.ts";
export interface PiServerOptions {
@ -19,7 +19,7 @@ export interface HostedHarnessHandle extends Pick<AgentHarness, "close"> {
}
/** Host capabilities used directly by the list and attach control-plane operations. */
export interface PiServerHost {
readonly sessions: Pick<SessionRepo, "list" | "open">;
createHarness(session: Session): Promise<HostedHarnessHandle>;
export interface PiServerHost<TMetadata extends SessionMetadata = SessionMetadata> {
readonly sessions: { list(): Promise<TMetadata[]> };
createHarness(metadata: TMetadata): Promise<HostedHarnessHandle>;
}

View file

@ -1,7 +1,9 @@
import type { SessionMetadata } from "@earendil-works/pi-agent-core";
import { afterEach, describe, expect, test } from "vitest";
import type { ByteConnection, ByteConnectionHandler } from "../src/connection.ts";
import { PiServer } from "../src/server.ts";
import { ProtocolTestClient, TestServerHost, type WireChannel } from "../src/testing/index.ts";
import type { PiServerHost } from "../src/types.ts";
const servers = new Set<PiServer>();
@ -80,7 +82,54 @@ describe("list and attach protocol", () => {
expect(host.harnesses.size).toBe(0);
});
test("attach opens the Session and creates one hosted Harness", async () => {
test("attach passes concrete repository metadata to the Harness host", async () => {
type BackendMetadata = SessionMetadata & { path: string; modifiedAt: number };
const metadata: BackendMetadata = {
id: "session-1",
createdAt: 1,
storageVersion: 1,
cwd: "/workspace",
path: "/sessions/session-1.jsonl",
modifiedAt: 2,
};
let received: BackendMetadata | undefined;
const host: PiServerHost<BackendMetadata> = {
sessions: { list: async () => [metadata] },
createHarness: async (candidate) => {
received = candidate;
return { close: async () => {} };
},
};
const server = new PiServer(host, {
listeners: [],
serverId: "00000000-0000-4000-8000-000000000001",
});
servers.add(server);
const client = connect(server);
await client.hello();
await expect(
client.request("00000000-0000-4000-8000-000000000001", { method: "list", args: [] }),
).resolves.toEqual({
type: "response",
id: "request-1",
ok: true,
result: [
{
id: "session-1",
createdAt: 1,
storageVersion: 1,
cwd: "/workspace",
},
],
});
await expect(
client.request("00000000-0000-4000-8000-000000000001", { method: "attach", args: ["session-1"] }),
).resolves.toMatchObject({ ok: true, result: { sessionId: "session-1" } });
expect(received).toBe(metadata);
});
test("attach creates one hosted Harness for concurrent callers", async () => {
const host = new TestServerHost();
await host.seed("session-1");
const server = createServer(host);
@ -136,6 +185,32 @@ describe("list and attach protocol", () => {
expect(host.harnesses.size).toBe(0);
});
test("rejects an ambiguous session ID without creating a Harness", async () => {
type BackendMetadata = SessionMetadata & { path: string };
const host: PiServerHost<BackendMetadata> = {
sessions: {
list: async () => [
{ id: "duplicate", createdAt: 1, storageVersion: 1, cwd: "/one", path: "/one/session.jsonl" },
{ id: "duplicate", createdAt: 2, storageVersion: 1, cwd: "/two", path: "/two/session.jsonl" },
],
},
createHarness: async () => {
throw new Error("must not create a Harness for an ambiguous session");
},
};
const server = new PiServer(host, {
listeners: [],
serverId: "00000000-0000-4000-8000-000000000001",
});
servers.add(server);
const client = connect(server);
await client.hello();
await expect(
client.request("00000000-0000-4000-8000-000000000001", { method: "attach", args: ["duplicate"] }),
).resolves.toMatchObject({ ok: false, error: { code: "session_ambiguous" } });
});
test("invalidates a terminated Harness handle and allows a later attach", async () => {
const host = new TestServerHost();
await host.seed("session-1");
@ -170,118 +245,16 @@ describe("list and attach protocol", () => {
});
});
describe("server draining", () => {
test("drains hosted sessions, acknowledges, and closes", async () => {
const host = new TestServerHost();
await host.seed("session-1");
const server = createServer(host);
const client = connect(server);
await client.hello();
await client.request("00000000-0000-4000-8000-000000000001", {
method: "attach",
args: ["session-1"],
});
const harness = host.latestHarness("session-1");
await expect(
client.request("00000000-0000-4000-8000-000000000001", { method: "drain", args: [] }),
).resolves.toMatchObject({
ok: true,
result: {},
});
await client.waitForClose();
await server.closed;
expect(harness.closeCount).toBe(1);
});
test("rejects a drain addressed to another server", async () => {
const host = new TestServerHost();
await host.seed("session-1");
const server = createServer(host);
const client = connect(server);
await client.hello();
await client.request("00000000-0000-4000-8000-000000000001", {
method: "attach",
args: ["session-1"],
});
await expect(
client.request("00000000-0000-4000-8000-000000000002", { method: "drain", args: [] }),
).resolves.toMatchObject({
ok: false,
error: { code: "wrong_server" },
});
expect(host.latestHarness("session-1").closeCount).toBe(0);
});
test("only the drain owner schedules shutdown", async () => {
const host = new TestServerHost();
await host.seed("session-1");
const server = createServer(host);
const owner = connect(server);
const concurrent = connect(server);
await Promise.all([owner.hello(), concurrent.hello()]);
await owner.request("00000000-0000-4000-8000-000000000001", {
method: "attach",
args: ["session-1"],
});
const gate = host.latestHarness("session-1").gateNextClose();
const draining = owner.request("00000000-0000-4000-8000-000000000001", { method: "drain", args: [] });
await gate.entered.promise;
await expect(
concurrent.request("00000000-0000-4000-8000-000000000001", { method: "drain", args: [] }),
).resolves.toMatchObject({
ok: false,
error: { code: "server_draining" },
});
expect(owner.closed).toBe(false);
gate.release.resolve(undefined);
await expect(draining).resolves.toMatchObject({ ok: true, result: {} });
await server.closed;
});
test("rejects drain acknowledgement when any hosted Harness fails to close", async () => {
const host = new TestServerHost();
await host.seed("session-1");
await host.seed("session-2");
const server = createServer(host);
const client = connect(server);
await client.hello();
await client.request("00000000-0000-4000-8000-000000000001", { method: "attach", args: ["session-1"] });
await client.request("00000000-0000-4000-8000-000000000001", { method: "attach", args: ["session-2"] });
const failedHarness = host.latestHarness("session-1");
const closedHarness = host.latestHarness("session-2");
failedHarness.failClose = new Error("close failed");
await expect(
client.request("00000000-0000-4000-8000-000000000001", { method: "drain", args: [] }),
).resolves.toMatchObject({
ok: false,
error: { code: "internal_error" },
});
await expect(server.closed).rejects.toThrow(/Failed to close hosted Harnesses/);
expect(failedHarness.closeCount).toBe(1);
expect(closedHarness.closeCount).toBe(1);
servers.delete(server);
await failedHarness.close();
});
});
describe("hosted Harness acquisition failures", () => {
test("shares an opening failure and allows a later retry", async () => {
test("shares a Harness creation failure, releases the Session, and allows a later retry", async () => {
const host = new TestServerHost();
await host.seed("session-1");
host.failNextOpen = new Error("open failed");
host.nextCreateHarnessError = new Error("Harness creation failed");
const server = createServer(host);
const first = connect(server);
const second = connect(server);
await Promise.all([first.hello(), second.hello()]);
const gate = host.gateNextOpen();
const gate = host.gateNextCreateHarness();
const firstAttach = first.request("00000000-0000-4000-8000-000000000001", {
method: "attach",
@ -298,31 +271,12 @@ describe("hosted Harness acquisition failures", () => {
{ ok: false, error: { code: "internal_error" } },
{ ok: false, error: { code: "internal_error" } },
]);
expect(host.openCount).toBe(1);
expect(host.createHarnessCount).toBe(1);
await expect(
first.request("00000000-0000-4000-8000-000000000001", { method: "attach", args: ["session-1"] }),
).resolves.toMatchObject({ ok: true, result: { sessionId: "session-1" } });
expect(host.openCount).toBe(2);
expect(host.harnesses.get("session-1")).toHaveLength(1);
});
test("releases the opened Session when Harness creation fails", async () => {
const host = new TestServerHost();
await host.seed("session-1");
host.failNextHarness = new Error("harness failed");
const server = createServer(host);
const client = connect(server);
await client.hello();
await expect(
client.request("00000000-0000-4000-8000-000000000001", { method: "attach", args: ["session-1"] }),
).resolves.toMatchObject({ ok: false, error: { code: "internal_error" } });
await expect(
client.request("00000000-0000-4000-8000-000000000001", { method: "attach", args: ["session-1"] }),
).resolves.toMatchObject({ ok: true, result: { sessionId: "session-1" } });
expect(host.openCount).toBe(2);
expect(host.createHarnessCount).toBe(2);
expect(host.harnesses.get("session-1")).toHaveLength(1);
});
@ -332,7 +286,7 @@ describe("hosted Harness acquisition failures", () => {
const server = createServer(host);
const client = connect(server);
await client.hello();
const gate = host.gateNextOpen();
const gate = host.gateNextCreateHarness();
const attach = client.request("00000000-0000-4000-8000-000000000001", {
method: "attach",
args: ["session-1"],
@ -345,4 +299,31 @@ describe("hosted Harness acquisition failures", () => {
await expect(attach).rejects.toThrow(/closed/i);
expect(host.latestHarness("session-1").closeCount).toBe(1);
});
test("fails shutdown when an in-flight acquisition cannot release its Harness", async () => {
const host = new TestServerHost();
await host.seed("session-1");
const cleanupError = new Error("close failed");
host.nextHarnessCloseError = cleanupError;
const server = createServer(host);
const client = connect(server);
await client.hello();
const gate = host.gateNextCreateHarness();
const attach = client.request("00000000-0000-4000-8000-000000000001", {
method: "attach",
args: ["session-1"],
});
await gate.entered.promise;
const closing = server.close();
gate.release.resolve(undefined);
await expect(closing).rejects.toThrow(/Failed to close hosted Harnesses/);
await expect(server.closed).rejects.toThrow(/Failed to close hosted Harnesses/);
await expect(attach).rejects.toThrow(/closed/i);
expect(host.latestHarness("session-1").closeCount).toBe(1);
servers.delete(server);
await host.latestHarness("session-1").close();
});
});

View file

@ -1,7 +1,7 @@
import { type ChildProcess, fork } from "node:child_process";
import { once } from "node:events";
import { lstat, mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { lstat, mkdtemp, readdir, readFile, rm, unlink, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import type { PiServer } from "../src/index.ts";
import { connectUnixTestClient, type ProtocolTestClient, TestServerHost } from "../src/testing/index.ts";
@ -100,6 +100,7 @@ describe("Unix listener filesystem lifecycle", () => {
const stats = await lstat(path);
expect(stats.isSocket()).toBe(true);
if (process.platform !== "win32") expect(stats.mode & 0o777).toBe(0o600);
expect(await readdir(dirname(path))).toEqual(["server.sock"]);
await server.close();
await expect(lstat(path)).rejects.toMatchObject({ code: "ENOENT" });

View file

@ -25,7 +25,6 @@
"@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/control": ["./packages/client/src/control.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"],

View file

@ -9,9 +9,9 @@ export const workspaceSourcePaths = {
aiOAuth: fileURLToPath(new URL("./packages/ai/src/oauth.ts", import.meta.url)),
aiProviders: fileURLToPath(new URL("./packages/ai/src/providers", import.meta.url)),
agentIndex: fileURLToPath(new URL("./packages/agent/src/index.ts", import.meta.url)),
agentNode: fileURLToPath(new URL("./packages/agent/src/node.ts", import.meta.url)),
protocolIndex: fileURLToPath(new URL("./packages/protocol/src/index.ts", import.meta.url)),
clientIndex: fileURLToPath(new URL("./packages/client/src/index.ts", import.meta.url)),
clientControl: fileURLToPath(new URL("./packages/client/src/control.ts", import.meta.url)),
clientUnix: fileURLToPath(new URL("./packages/client/src/unix.ts", import.meta.url)),
serverIndex: fileURLToPath(new URL("./packages/server/src/index.ts", import.meta.url)),
serverUnix: fileURLToPath(new URL("./packages/server/src/transports/unix/index.ts", import.meta.url)),
@ -32,9 +32,9 @@ export default defineConfig({
replacement: `${workspaceSourcePaths.aiProviders}/$1.ts`,
},
{ find: /^@earendil-works\/pi-agent-core$/, replacement: workspaceSourcePaths.agentIndex },
{ find: /^@earendil-works\/pi-agent-core\/node$/, replacement: workspaceSourcePaths.agentNode },
{ find: /^@earendil-works\/pi-protocol$/, replacement: workspaceSourcePaths.protocolIndex },
{ find: /^@earendil-works\/pi-client$/, replacement: workspaceSourcePaths.clientIndex },
{ find: /^@earendil-works\/pi-client\/control$/, replacement: workspaceSourcePaths.clientControl },
{ find: /^@earendil-works\/pi-client\/unix$/, replacement: workspaceSourcePaths.clientUnix },
{ find: /^@earendil-works\/pi-server$/, replacement: workspaceSourcePaths.serverIndex },
{ find: /^@earendil-works\/pi-server\/unix$/, replacement: workspaceSourcePaths.serverUnix },