Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
],
"scripts": {
"clean": "npm run clean --workspaces",
"build": "cd packages/tui && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../storage/sqlite-node && npm run build && cd ../../protocol && npm run build && cd ../coding-agent && npm run build && cd ../server && npm run build",
"build:offline": "cd packages/tui && npm run build && cd ../ai && npm run build:offline && cd ../agent && npm run build && cd ../storage/sqlite-node && npm run build && cd ../../protocol && npm run build && cd ../coding-agent && npm run build && cd ../server && npm run build",
"build": "cd packages/tui && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../storage/sqlite-node && npm run build && cd ../../protocol && npm run build && cd ../client && npm run build && cd ../coding-agent && npm run build && cd ../server && npm run build",
"build:offline": "cd packages/tui && npm run build && cd ../ai && npm run build:offline && cd ../agent && npm run build && cd ../storage/sqlite-node && npm run build && cd ../../protocol && npm run build && cd ../client && npm run build && cd ../coding-agent && npm run build && cd ../server && npm run build",
"check": "biome check --write --error-on-warnings . && npm run check:pinned-deps && npm run check:ts-imports && npm run check:shrinkwrap && npm run check:install-lock:coding-agent && tsgo --noEmit && npm run check:browser-smoke",
"check:browser-smoke": "node scripts/check-browser-smoke.mjs",
"check:pinned-deps": "node scripts/check-pinned-deps.mjs",
Expand Down
16 changes: 10 additions & 6 deletions packages/agent/src/harness/agent-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1100,19 +1100,23 @@ export class AgentHarness<
this.streamOptions = cloneStreamOptions(streamOptions);
}

/**
* Permanently stop this harness instance without deleting its durable session.
* Clears queued work, aborts the active operation, and waits for it to settle.
*/
async shutdown(): Promise<void> {
if (this.shutdownPromise) return this.shutdownPromise;
/** Permanently stop this harness instance without deleting its durable session. */
requestShutdown(): void {
if (this.isShutdown) return;
this.isShutdown = true;
this.pendingSessionWrites = [];
this.steerQueue = [];
this.followUpQueue = [];
this.nextTurnQueue = [];
this.activeAbortController?.abort();
this.shutdownPromise = this.waitForTasks();
}

/** Waits for work active when shutdown was requested to settle. */
waitForShutdown(): Promise<void> {
if (!this.shutdownPromise) {
return Promise.reject(new AgentHarnessError("invalid_state", "Shutdown has not been requested"));
}
return this.shutdownPromise;
}

Expand Down
77 changes: 70 additions & 7 deletions packages/agent/test/harness/agent-harness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,19 @@ describe("AgentHarness", () => {
expect(harness.getFollowUpMode()).toBe("one-at-a-time");
});

it("rejects waiting before shutdown is requested", async () => {
const harness = new AgentHarness({
models,
session: new Session(new InMemorySessionStorage()),
model: getModel("anthropic", "claude-sonnet-4-5"),
});

await expect(harness.waitForShutdown()).rejects.toMatchObject({
code: "invalid_state",
message: "Shutdown has not been requested",
});
});

it("shuts down active work permanently and idempotently", async () => {
const registration = newFaux();
const entered = deferred();
Expand All @@ -156,10 +169,12 @@ describe("AgentHarness", () => {
await harness.nextTurn("queued next turn");

let firstShutdownSettled = false;
const firstShutdown = harness.shutdown().then(() => {
harness.requestShutdown();
const firstShutdown = harness.waitForShutdown().then(() => {
firstShutdownSettled = true;
});
const secondShutdown = harness.shutdown();
harness.requestShutdown();
const secondShutdown = harness.waitForShutdown();
await Promise.resolve();

expect(signal?.aborted).toBe(true);
Expand All @@ -177,6 +192,49 @@ describe("AgentHarness", () => {
});
});

it("allows a hook to request shutdown without deadlocking its operation", async () => {
const registration = newFaux();
let providerCalls = 0;
registration.setResponses([
() => {
providerCalls++;
return fauxAssistantMessage("must not run");
},
]);
const harness = new AgentHarness({
models,
session: new Session(new InMemorySessionStorage()),
model: registration.getModel(),
});
harness.on("before_agent_start", () => {
harness.requestShutdown();
return undefined;
});

await expect(harness.prompt("hello")).rejects.toMatchObject({ code: "invalid_state" });
await expect(harness.waitForShutdown()).resolves.toBeUndefined();
expect(providerCalls).toBe(0);
});

it("allows a subscriber to request shutdown without deadlocking its operation", async () => {
const registration = newFaux();
registration.setResponses([() => fauxAssistantMessage("reply")]);
const harness = new AgentHarness({
models,
session: new Session(new InMemorySessionStorage()),
model: registration.getModel(),
});
let subscriberCalls = 0;
harness.subscribe(() => {
subscriberCalls++;
harness.requestShutdown();
});

await expect(harness.prompt("hello")).resolves.toMatchObject({ role: "assistant", stopReason: "aborted" });
await expect(harness.waitForShutdown()).resolves.toBeUndefined();
expect(subscriberCalls).toBeGreaterThan(1);
});

it("does not start a provider request when shutdown occurs during before_agent_start", async () => {
const registration = newFaux();
const entered = deferred();
Expand All @@ -202,7 +260,8 @@ describe("AgentHarness", () => {
await entered.promise;

let shutdownSettled = false;
const shutdown = harness.shutdown().then(() => {
harness.requestShutdown();
const shutdown = harness.waitForShutdown().then(() => {
shutdownSettled = true;
});
await Promise.resolve();
Expand Down Expand Up @@ -235,7 +294,8 @@ describe("AgentHarness", () => {
await entered.promise;

let shutdownSettled = false;
const shutdown = harness.shutdown().then(() => {
harness.requestShutdown();
const shutdown = harness.waitForShutdown().then(() => {
shutdownSettled = true;
});
await Promise.resolve();
Expand Down Expand Up @@ -271,7 +331,8 @@ describe("AgentHarness", () => {
await entered.promise;

let shutdownSettled = false;
const shutdown = harness.shutdown().then(() => {
harness.requestShutdown();
const shutdown = harness.waitForShutdown().then(() => {
shutdownSettled = true;
});
await Promise.resolve();
Expand Down Expand Up @@ -319,7 +380,8 @@ describe("AgentHarness", () => {
];
await storage.allWritesStarted.promise;

const shutdown = harness.shutdown();
harness.requestShutdown();
const shutdown = harness.waitForShutdown();
const firstSettlement = await Promise.race([
shutdown.then(() => "shutdown" as const),
new Promise<"writes-pending">((resolve) => setImmediate(() => resolve("writes-pending"))),
Expand All @@ -340,7 +402,8 @@ describe("AgentHarness", () => {
model: getModel("anthropic", "claude-sonnet-4-5"),
});

await harness.shutdown();
harness.requestShutdown();
await harness.waitForShutdown();

const messages = (await session.getEntries()).flatMap((entry) =>
entry.type === "message" ? [entry.message] : [],
Expand Down
7 changes: 7 additions & 0 deletions packages/client/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Changelog

## [Unreleased]

### Added

- Added the experimental transport-neutral `PiClient` and multi-session `PiSessionHandle` APIs with structured `PiServerError` responses.
40 changes: 40 additions & 0 deletions packages/client/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# @earendil-works/pi-client

Transport-neutral client for remote pi sessions. `PiClient` exchanges length-prefixed CBOR messages through a small `ByteTransport` interface. The package has no Node-specific imports.

```ts
import { PiClient, type ByteTransportFactory } from "@earendil-works/pi-client";

const transportFactory: ByteTransportFactory = async (handlers) => {
// Connect using WebSocket, Unix socket, or another ordered byte transport.
return {
async send(chunk) {
// Deliver chunks in invocation order and honor backpressure.
},
close() {},
};
};

const client = new PiClient({ token: bearerToken, transportFactory });
await client.connect();
const session = await client.createSession({ cwd: "/workspace" });
const unsubscribe = session.subscribe((snapshot) => render(snapshot));
await session.prompt("Inspect this project");
unsubscribe();
```

Call `handlers.onData(chunk)` for inbound bytes, `handlers.onClose()` for an orderly terminal close, and `handlers.onError(error)` for transport failures. A factory must create a fresh transport for every connection attempt.

`PiClient` does not reconnect automatically. Call `reconnect()` after disconnection. One connection can attach several sessions. Requests are correlated by ID. Server snapshots and successful response snapshots are authoritative, while progress events do not mutate snapshot state optimistically. Read cached session summaries from `client.snapshot?.sessions`; call `listSessions()` to request a refreshed list from the server.

`createSession()` and `attachSession()` return a `PiSessionHandle`; handles cannot be constructed directly. A returned handle is attached and remains a stable client-side reference for that session. Explicit detach, server removal, or disconnection makes a retained handle unavailable for commands. Its latest snapshot remains readable after detach or disconnection unless the server removes the session. Calling `attachSession()` again reacquires the session and returns the existing handle. Commands fail with `PiDisconnectedError` while the client is disconnected and `PiSessionDetachedError` when the client is connected but the session is detached.

`subscribe()` observes authoritative snapshots. `onEvent()` observes protocol events. Both return an unsubscribe function. Structured errors returned by the server are exposed as `PiServerError`.

## Limits and security

`PiClientOptions.maxFrameLength` bounds inbound and outbound CBOR payloads. Configure matching limits on the client and server. Transports should separately bound queued outbound bytes and preserve send order.

Treat peers as untrusted. Use a secure transport where required and protect the protocol bearer token.

Subscriber exceptions are isolated from protocol state. Set `onListenerError` in `PiClientOptions` to report them to application logging or diagnostics.
35 changes: 35 additions & 0 deletions packages/client/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"name": "@earendil-works/pi-client",
"version": "0.83.0",
"description": "Transport-neutral client for remote pi sessions over framed CBOR bytes",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./package.json": "./package.json"
},
"sideEffects": false,
"files": ["dist", "README.md", "CHANGELOG.md"],
"scripts": {
"clean": "shx rm -rf dist",
"build": "tsgo -p tsconfig.build.json",
"test": "vitest --run",
"typecheck": "tsgo -p tsconfig.test.json",
"prepublishOnly": "npm run clean && npm run build"
},
"keywords": ["pi", "client", "protocol", "cbor", "binary"],
"author": "Earendil Works",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/earendil-works/pi.git",
"directory": "packages/client"
},
"engines": { "node": ">=22.19.0" },
"dependencies": { "@earendil-works/pi-protocol": "^0.83.0" },
"devDependencies": { "shx": "0.4.0", "vitest": "4.1.9" }
}
Loading
Loading