feat(coding-agent): add client capability binding experiments

This commit is contained in:
Christian Klotz 2026-07-24 23:34:15 +03:00
parent 8eef62ed3e
commit fec371a33f
No known key found for this signature in database
21 changed files with 3612 additions and 0 deletions

View file

@ -0,0 +1,102 @@
# Cap'n Web client binding spike
A separate version of the client-capability spike using [Cap'n Web](https://github.com/cloudflare/capnweb) for symmetric, object-capability RPC.
It demonstrates a server extension invoking a typed client binding without knowing whether the originating client is a TUI or browser. The TUI renders notifications in yellow. The browser displays them centrally and dramatically spins them away.
## Architecture
```text
browser/TUI exports ClientBindingApi server exports ServerApi
┌──────────────────────────────┐ ┌───────────────────────────┐
│ event(event) │◀─────────│ prompt(text) │
│ notify({ message }) │─────────▶│ command(name, input) │
└──────────────────────────────┘ Cap'n Web└───────────────────────────┘
▲ │
│ ▼
└──── originating-client stub ─ notify extension
├─ notify command
├─ notify LLM tool
└─ exact-Paris hook
```
Every WebSocket connection is a symmetric Cap'n Web session:
- The server exports a connection-scoped `ServerApi` as its main RPC interface.
- The client exports `ClientBindingApi` as its main RPC interface.
- The client calls `connect()` with binding IDs and versions.
- The server retains that connection's remote client stub.
- A prompt arrives through the connection-scoped server object, so the originating client is known without trusting a caller-supplied client ID on every call.
- Commands, tools, and hooks call `context.clientBindings.notify()`. Cap'n Web handles reverse invocation, request correlation, promise resolution, and remote errors.
Pi still owns semantic concerns that Cap'n Web does not provide: binding names and versions, originating-client selection, runtime validation, timeouts, and extension-to-tool exposure. TypeBox validates RPC values because Cap'n Web's TypeScript interfaces do not perform runtime validation.
Unlike the HTTP/SSE spike, prompts, events, commands, and client binding calls all use one WebSocket. Plain HTTP only serves the static browser files.
This remains a local spike. Authentication, persistent sessions, reconnect replay, controller leases, and production authorization are intentionally omitted. Cap'n Web is also currently described by its maintainers as experimental.
## Install
The experimental dependencies are isolated from the coding-agent package:
```bash
npm install --prefix packages/coding-agent/examples/capability-capnweb-spike --ignore-scripts
```
## Run
From the repository root:
```bash
OPENAI_API_KEY=... npm run server --prefix packages/coding-agent/examples/capability-capnweb-spike
```
The default model is `openai/gpt-5.4-nano`. Select another registered OpenAI or Anthropic model with:
```bash
PI_CAPNWEB_SPIKE_MODEL=anthropic/claude-haiku-4-5 ANTHROPIC_API_KEY=... \
npm run server --prefix packages/coding-agent/examples/capability-capnweb-spike
```
The server starts without provider credentials, which is enough to exercise direct binding calls. LLM prompts require the selected provider's environment credential.
Open [http://127.0.0.1:8788](http://127.0.0.1:8788) for the browser client.
In another terminal, run the TUI:
```bash
npm run tui --prefix packages/coding-agent/examples/capability-capnweb-spike
```
Try the direct binding path:
```text
/notify TUI binding confirmed.
```
Or prompt the model:
```text
Notify me that Cap'n Web works, then briefly explain what happened.
```
When multiple clients are connected, notifications target only the client that submitted the prompt.
For a deterministic extension-initiated call without an LLM tool call, ask:
```text
What is the capital of France?
```
The system prompt makes the assistant answer exactly `Paris`. The extension observes that completed response and calls the originating client's `notify()` binding with `❤️`.
## Files
- `protocol.ts`: shared TypeScript RPC interfaces, binding metadata, and TypeBox schemas
- `binding-registry.ts`: connected-client routing, negotiation, validation, timeout, and stub invocation
- `extension.ts`: binding-facing command, LLM tool, and exact-`Paris` response hook
- `server.ts`: `AgentHarness`, HTTP static server, and connection-scoped Cap'n Web APIs
- `ws-transport.ts`: typed adapter between Node's `ws` server and `RpcSession`
- `tui.ts`: Cap'n Web TUI client
- `web/`: static Cap'n Web browser client
- `package.json`: isolated, exact-version Cap'n Web and WebSocket dependencies

View file

@ -0,0 +1,137 @@
import type { RpcStub } from "capnweb";
import { Value } from "typebox/value";
import {
type ClientBindingApi,
type ClientRegistration,
type NotifyInput,
type NotifyOutput,
notifyBinding,
type ServerEvent,
} from "./protocol.ts";
export class BindingError extends Error {
readonly code:
| "client_unavailable"
| "binding_unavailable"
| "invalid_input"
| "invalid_output"
| "aborted"
| "timeout";
constructor(
code: "client_unavailable" | "binding_unavailable" | "invalid_input" | "invalid_output" | "aborted" | "timeout",
message: string,
) {
super(message);
this.name = "BindingError";
this.code = code;
}
}
export interface ConnectedClient {
readonly id: string;
readonly bindings: ReadonlyMap<string, number>;
readonly rpc: RpcStub<ClientBindingApi>;
connected: boolean;
}
export class BindingRegistry {
readonly #clientsById = new Map<string, ConnectedClient>();
register(registration: ClientRegistration, rpc: RpcStub<ClientBindingApi>): ConnectedClient {
if (this.#clientsById.has(registration.clientId)) {
throw new BindingError("client_unavailable", `Client ${registration.clientId} is already connected`);
}
const client: ConnectedClient = {
id: registration.clientId,
bindings: new Map(registration.bindings.map((binding) => [binding.id, binding.version])),
rpc,
connected: true,
};
this.#clientsById.set(client.id, client);
return client;
}
unregister(client: ConnectedClient): void {
if (this.#clientsById.get(client.id) !== client) return;
client.connected = false;
this.#clientsById.delete(client.id);
}
async send(client: ConnectedClient, event: ServerEvent): Promise<void> {
this.#requireConnected(client);
const call = client.rpc.event(event);
try {
await call;
} finally {
call[Symbol.dispose]();
}
}
async broadcast(event: ServerEvent): Promise<void> {
await Promise.all(
[...this.#clientsById.values()].map(async (client) => {
try {
await this.send(client, event);
} catch {
this.unregister(client);
}
}),
);
}
async notify(
client: ConnectedClient,
input: unknown,
options: { signal?: AbortSignal; timeoutMs?: number } = {},
): Promise<NotifyOutput> {
this.#requireConnected(client);
if (client.bindings.get(notifyBinding.id) !== notifyBinding.version) {
throw new BindingError(
"binding_unavailable",
`Client ${client.id} does not provide ${notifyBinding.id}@${notifyBinding.version}`,
);
}
if (!Value.Check(notifyBinding.input, input)) {
throw new BindingError("invalid_input", `Input does not match ${notifyBinding.id}@${notifyBinding.version}`);
}
if (options.signal?.aborted) {
throw new BindingError("aborted", `${notifyBinding.id} was aborted`);
}
const rpcCall = client.rpc.notify(input as NotifyInput);
const timeoutMs = options.timeoutMs ?? 10_000;
let rejectCancellation: ((error: Error) => void) | undefined;
const cancellation = new Promise<never>((_resolve, reject) => {
rejectCancellation = reject;
});
const timer = setTimeout(() => {
rejectCancellation?.(new BindingError("timeout", `${notifyBinding.id} timed out after ${timeoutMs}ms`));
}, timeoutMs);
const onAbort = () => {
rejectCancellation?.(new BindingError("aborted", `${notifyBinding.id} was aborted`));
};
options.signal?.addEventListener("abort", onAbort, { once: true });
try {
const output = await Promise.race([rpcCall, cancellation]);
if (!Value.Check(notifyBinding.output, output)) {
throw new BindingError(
"invalid_output",
`Output does not match ${notifyBinding.id}@${notifyBinding.version}`,
);
}
return { displayed: true };
} finally {
clearTimeout(timer);
options.signal?.removeEventListener("abort", onAbort);
rpcCall[Symbol.dispose]();
}
}
#requireConnected(client: ConnectedClient): void {
if (!client.connected || this.#clientsById.get(client.id) !== client) {
throw new BindingError("client_unavailable", `Client ${client.id} is not connected`);
}
}
}

View file

@ -0,0 +1,77 @@
import type { AgentHarnessEvent, AgentHarnessTool } from "@earendil-works/pi-agent-core";
import { contentText } from "@earendil-works/pi-ai";
import { Type } from "typebox";
import type { NotifyInput, NotifyOutput } from "./protocol.ts";
export interface ClientBindings {
notify(input: NotifyInput, options?: { signal?: AbortSignal; timeoutMs?: number }): Promise<NotifyOutput>;
}
export interface NotifyExtensionContext {
clientBindings: ClientBindings;
}
export type ServerCommand = (input: unknown, context: NotifyExtensionContext, signal?: AbortSignal) => Promise<unknown>;
export type MessageEndEvent = Extract<AgentHarnessEvent, { type: "message_end" }>;
export type MessageEndHandler = (
event: MessageEndEvent,
context: NotifyExtensionContext,
signal?: AbortSignal,
) => Promise<void> | void;
export interface ServerExtensionApi {
registerTool(tool: AgentHarnessTool<NotifyExtensionContext>): void;
registerCommand(name: string, command: ServerCommand): void;
on(type: "message_end", handler: MessageEndHandler): void;
}
async function notifyClient(
input: unknown,
context: NotifyExtensionContext,
signal?: AbortSignal,
): Promise<NotifyOutput> {
if (
typeof input !== "object" ||
input === null ||
!("message" in input) ||
typeof input.message !== "string" ||
input.message.trim().length === 0
) {
throw new Error("notify requires a non-empty message");
}
return context.clientBindings.notify({ message: input.message.trim() }, { signal });
}
const notifyParameters = Type.Object(
{
message: Type.String({ description: "Notification text", minLength: 1, maxLength: 500 }),
},
{ additionalProperties: false },
);
export function notifyExtension(pi: ServerExtensionApi): void {
pi.registerCommand("notify", notifyClient);
pi.on("message_end", async (event, context, signal) => {
if (event.message.role !== "assistant") return;
if (contentText(event.message.content, "").trim() === "Paris") {
await notifyClient({ message: "❤️" }, context, signal);
}
});
pi.registerTool({
name: "notify",
label: "Notify client",
description: "Show a brief notification on the client that submitted the current prompt.",
parameters: notifyParameters,
async execute(_toolCallId, params, signal, _onUpdate, context) {
const input = typeof params === "object" && params !== null ? params : {};
const message = "message" in input && typeof input.message === "string" ? input.message : "";
await notifyClient({ message }, context, signal);
return {
content: [{ type: "text", text: `Notified the originating client: ${message}` }],
details: { displayed: true },
};
},
});
}

View file

@ -0,0 +1,595 @@
{
"name": "pi-capability-capnweb-spike",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-capability-capnweb-spike",
"dependencies": {
"capnweb": "0.10.0",
"ws": "8.21.1"
},
"devDependencies": {
"@types/node": "24.12.4",
"@types/ws": "8.18.1",
"tsx": "4.22.1"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@types/node": {
"version": "24.12.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz",
"integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/capnweb": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/capnweb/-/capnweb-0.10.0.tgz",
"integrity": "sha512-lQ2nlzaifkqBeNfs8RLErn7NZ8FNf3vn1W7J3nSH73edWG+0B5nHZGR51S2S9HMZEuZcEypOO469dZRY/BluFA==",
"license": "MIT",
"workspaces": [
".",
"packages/*"
]
},
"node_modules/esbuild": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/tsx": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.1.tgz",
"integrity": "sha512-TvncJykhxAzFCk0VQZKBTClall4Pm7qXDSodb6uxi8QFa8X8mT6ABjxxsQ2opDRYxG7AzcRWXaFtruz5HJKuWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.28.0"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true,
"license": "MIT"
},
"node_modules/ws": {
"version": "8.21.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

View file

@ -0,0 +1,18 @@
{
"name": "pi-capability-capnweb-spike",
"private": true,
"type": "module",
"scripts": {
"server": "tsx server.ts",
"tui": "tsx tui.ts"
},
"dependencies": {
"capnweb": "0.10.0",
"ws": "8.21.1"
},
"devDependencies": {
"@types/node": "24.12.4",
"@types/ws": "8.18.1",
"tsx": "4.22.1"
}
}

View file

@ -0,0 +1,76 @@
import { type Static, Type } from "typebox";
export const protocolVersion = 1;
export const notifyBinding = {
id: "pi.notify",
version: 1,
input: Type.Object(
{
message: Type.String({ minLength: 1, maxLength: 500 }),
},
{ additionalProperties: false },
),
output: Type.Object(
{
displayed: Type.Literal(true),
},
{ additionalProperties: false },
),
} as const;
export type NotifyInput = Static<typeof notifyBinding.input>;
export type NotifyOutput = Static<typeof notifyBinding.output>;
export interface BindingRegistration {
id: string;
version: number;
}
export interface ClientRegistration {
clientId: string;
protocolVersion: number;
bindings: BindingRegistration[];
}
export const clientRegistrationSchema = Type.Object(
{
clientId: Type.String({ minLength: 1, maxLength: 80, pattern: "^[a-zA-Z0-9_-]+$" }),
protocolVersion: Type.Literal(protocolVersion),
bindings: Type.Array(
Type.Object(
{
id: Type.String({ minLength: 1, maxLength: 100 }),
version: Type.Integer({ minimum: 1 }),
},
{ additionalProperties: false },
),
),
},
{ additionalProperties: false },
);
export type ServerEvent =
| { type: "ready"; clientId: string; bindings: BindingRegistration[] }
| { type: "user_prompt"; clientId: string; text: string }
| { type: "run_start" }
| { type: "run_end" }
| { type: "assistant_start" }
| { type: "assistant_delta"; text: string }
| { type: "assistant_end" }
| { type: "tool_start"; name: string }
| { type: "tool_end"; name: string; isError: boolean }
| { type: "error"; message: string };
/** The main Cap'n Web interface exported by each connected client. */
export interface ClientBindingApi {
event(event: ServerEvent): Promise<void>;
notify(input: NotifyInput): Promise<NotifyOutput>;
}
/** The main Cap'n Web interface exported by the server for one connection. */
export interface ServerApi {
connect(registration: ClientRegistration): Promise<void>;
prompt(text: string): Promise<void>;
command(name: string, input: unknown): Promise<void>;
}

View file

@ -0,0 +1,292 @@
import { readFile } from "node:fs/promises";
import { createServer, type ServerResponse } from "node:http";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
AgentHarness,
type AgentHarnessEvent,
type AgentHarnessTool,
InMemorySessionStorage,
Session,
} from "@earendil-works/pi-agent-core";
import { createModels, type Model } from "@earendil-works/pi-ai";
import { anthropicProvider } from "@earendil-works/pi-ai/providers/anthropic";
import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";
import { RpcSession, type RpcStub, RpcTarget } from "capnweb";
import { Value } from "typebox/value";
import { WebSocketServer } from "ws";
import { BindingRegistry, type ConnectedClient } from "./binding-registry.ts";
import {
type MessageEndHandler,
type NotifyExtensionContext,
notifyExtension,
type ServerCommand,
type ServerExtensionApi,
} from "./extension.ts";
import {
type ClientBindingApi,
type ClientRegistration,
clientRegistrationSchema,
type ServerApi,
type ServerEvent,
} from "./protocol.ts";
import { NodeWebSocketTransport } from "./ws-transport.ts";
const host = process.env.PI_CAPNWEB_SPIKE_HOST ?? "127.0.0.1";
const port = Number(process.env.PI_CAPNWEB_SPIKE_PORT ?? "8788");
const modelSpec = process.env.PI_CAPNWEB_SPIKE_MODEL ?? "openai/gpt-5.4-nano";
const [providerId, modelId] = modelSpec.split("/", 2);
if (!providerId || !modelId) {
throw new Error(`PI_CAPNWEB_SPIKE_MODEL must use provider/model syntax, received ${modelSpec}`);
}
const models = createModels();
models.setProvider(openaiProvider());
models.setProvider(anthropicProvider());
function createFallbackModel(provider: string, id: string): Model<"openai-responses" | "anthropic-messages"> {
const common = {
id,
name: id,
reasoning: false,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 16_384,
};
if (provider === "openai") {
return {
...common,
api: "openai-responses",
provider: "openai",
baseUrl: "https://api.openai.com/v1",
input: ["text"],
};
}
if (provider === "anthropic") {
return {
...common,
api: "anthropic-messages",
provider: "anthropic",
baseUrl: "https://api.anthropic.com",
input: ["text"],
};
}
throw new Error(`Unsupported provider ${provider}. This spike registers openai and anthropic.`);
}
const model = models.getModel(providerId, modelId) ?? createFallbackModel(providerId, modelId);
const registry = new BindingRegistry();
const tools: AgentHarnessTool<NotifyExtensionContext>[] = [];
const commands = new Map<string, ServerCommand>();
const messageEndHandlers: MessageEndHandler[] = [];
const extensionApi: ServerExtensionApi = {
registerTool(tool) {
tools.push(tool);
},
registerCommand(name, command) {
commands.set(name, command);
},
on(type, handler) {
if (type === "message_end") messageEndHandlers.push(handler);
},
};
notifyExtension(extensionApi);
let activeOriginClient: ConnectedClient | undefined;
let promptInFlight = false;
function contextFor(client: ConnectedClient): NotifyExtensionContext {
return {
clientBindings: {
notify: (input, options) => registry.notify(client, input, options),
},
};
}
const harness = new AgentHarness({
session: new Session(new InMemorySessionStorage()),
models,
model,
thinkingLevel: "off",
tools,
toolContext: () => {
if (!activeOriginClient) throw new Error("No originating client is bound to this turn");
return contextFor(activeOriginClient);
},
systemPrompt: [
"You are a concise assistant.",
"When asked for the capital of France, respond with exactly Paris and nothing else.",
"When the user asks for a notification, call the notify tool with the exact message they want shown.",
].join(" "),
});
harness.subscribe(async (event, signal) => {
for (const wireEvent of toServerEvents(event)) await registry.broadcast(wireEvent);
if (event.type === "message_end" && messageEndHandlers.length > 0) {
if (!activeOriginClient) throw new Error("No originating client is bound to this assistant response");
const context = contextFor(activeOriginClient);
for (const handler of messageEndHandlers) await handler(event, context, signal);
}
});
function toServerEvents(event: AgentHarnessEvent): ServerEvent[] {
switch (event.type) {
case "agent_start":
return [{ type: "run_start" }];
case "agent_end":
return [{ type: "run_end" }];
case "message_start":
return event.message.role === "assistant" ? [{ type: "assistant_start" }] : [];
case "message_update":
return event.assistantMessageEvent.type === "text_delta"
? [{ type: "assistant_delta", text: event.assistantMessageEvent.delta }]
: [];
case "message_end":
if (event.message.role !== "assistant") return [];
return [
{ type: "assistant_end" },
...(event.message.errorMessage ? [{ type: "error" as const, message: event.message.errorMessage }] : []),
];
case "tool_execution_start":
return [{ type: "tool_start", name: event.toolName }];
case "tool_execution_end":
return [{ type: "tool_end", name: event.toolName, isError: event.isError }];
default:
return [];
}
}
async function runPrompt(client: ConnectedClient, text: string): Promise<void> {
if (promptInFlight) throw new Error("The shared session is already processing a prompt");
promptInFlight = true;
activeOriginClient = client;
await registry.broadcast({ type: "user_prompt", clientId: client.id, text });
try {
const assistant = await harness.prompt(text);
if (assistant.stopReason === "error") throw new Error(assistant.errorMessage ?? "Agent run failed");
} finally {
activeOriginClient = undefined;
promptInFlight = false;
}
}
class PiServerConnection extends RpcTarget implements ServerApi {
readonly #remoteClient: () => RpcStub<ClientBindingApi>;
#client: ConnectedClient | undefined;
constructor(remoteClient: () => RpcStub<ClientBindingApi>) {
super();
this.#remoteClient = remoteClient;
}
async connect(registration: ClientRegistration): Promise<void> {
if (this.#client) throw new Error("This RPC session is already registered");
if (!Value.Check(clientRegistrationSchema, registration)) throw new Error("Invalid client registration");
const client = registry.register(registration, this.#remoteClient());
this.#client = client;
try {
await registry.send(client, { type: "ready", clientId: client.id, bindings: registration.bindings });
} catch (error) {
registry.unregister(client);
this.#client = undefined;
throw error;
}
}
async prompt(text: string): Promise<void> {
const client = this.#requireClient();
if (typeof text !== "string" || text.trim().length === 0) throw new Error("Prompt is required");
await runPrompt(client, text.trim());
}
async command(name: string, input: unknown): Promise<void> {
const client = this.#requireClient();
if (typeof name !== "string") throw new Error("Command name must be a string");
const command = commands.get(name);
if (!command) throw new Error(`Unknown command ${name}`);
await command(input, contextFor(client));
}
[Symbol.dispose](): void {
if (!this.#client) return;
registry.unregister(this.#client);
this.#client = undefined;
}
#requireClient(): ConnectedClient {
if (!this.#client?.connected) throw new Error("RPC client has not connected");
return this.#client;
}
}
function sendStatic(response: ServerResponse, path: string, type: string): Promise<void> {
return readFile(path).then((body) => {
response.writeHead(200, { "content-type": type, "cache-control": "no-store" });
response.end(body);
});
}
function sendJson(response: ServerResponse, status: number, body: unknown): void {
response.writeHead(status, {
"content-type": "application/json; charset=utf-8",
"cache-control": "no-store",
});
response.end(JSON.stringify(body));
}
const exampleRoot = dirname(fileURLToPath(import.meta.url));
const webRoot = join(exampleRoot, "web");
const capnwebBrowserModule = fileURLToPath(import.meta.resolve("capnweb"));
const staticFiles = new Map([
["/", { path: join(webRoot, "index.html"), type: "text/html; charset=utf-8" }],
["/app.js", { path: join(webRoot, "app.js"), type: "text/javascript; charset=utf-8" }],
["/styles.css", { path: join(webRoot, "styles.css"), type: "text/css; charset=utf-8" }],
["/vendor/capnweb.js", { path: capnwebBrowserModule, type: "text/javascript; charset=utf-8" }],
]);
const httpServer = createServer(async (request, response) => {
try {
const url = new URL(request.url ?? "/", `http://${request.headers.host ?? `${host}:${port}`}`);
const staticFile = request.method === "GET" ? staticFiles.get(url.pathname) : undefined;
if (staticFile) {
await sendStatic(response, staticFile.path, staticFile.type);
return;
}
sendJson(response, 404, { ok: false, error: "Not found" });
} catch (error) {
sendJson(response, 500, { ok: false, error: error instanceof Error ? error.message : String(error) });
}
});
const wsServer = new WebSocketServer({
server: httpServer,
path: "/api/rpc",
maxPayload: 64 * 1024,
perMessageDeflate: false,
});
wsServer.on("connection", (socket) => {
let remoteClient: RpcStub<ClientBindingApi> | undefined;
const connection = new PiServerConnection(() => {
if (!remoteClient) throw new Error("Remote client binding is not ready");
return remoteClient;
});
const session = new RpcSession<ClientBindingApi>(new NodeWebSocketTransport(socket), connection, {
limits: { maxMessageSize: 64 * 1024 },
});
remoteClient = session.getRemoteMain();
socket.once("close", () => connection[Symbol.dispose]());
});
httpServer.listen(port, host, async () => {
const auth = await models.checkAuth(providerId);
console.log(`Cap'n Web capability spike listening at http://${host}:${port}`);
console.log(`Model: ${modelSpec}${auth ? ` (${auth.source})` : " (provider auth not configured)"}`);
console.log("Run the TUI with: npm run tui --prefix packages/coding-agent/examples/capability-capnweb-spike");
});
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.once(signal, () => {
wsServer.close();
httpServer.close(() => process.exit(0));
});
}

View file

@ -0,0 +1,136 @@
import { randomUUID } from "node:crypto";
import { createInterface } from "node:readline";
import { newWebSocketRpcSession, type RpcPromise, RpcTarget } from "capnweb";
import { Value } from "typebox/value";
import {
type ClientBindingApi,
type NotifyInput,
type NotifyOutput,
notifyBinding,
protocolVersion,
type ServerApi,
type ServerEvent,
} from "./protocol.ts";
const baseUrl = process.env.PI_CAPNWEB_SPIKE_URL ?? "http://127.0.0.1:8788";
const rpcUrl = new URL("/api/rpc", baseUrl);
rpcUrl.protocol = rpcUrl.protocol === "https:" ? "wss:" : "ws:";
const clientId = `tui-${randomUUID()}`;
const readline = createInterface({ input: process.stdin, output: process.stdout, prompt: "\x1b[1mYou ›\x1b[0m " });
let assistantLineOpen = false;
let busy = false;
let runActive = false;
function printLine(text: string): void {
if (assistantLineOpen) {
process.stdout.write("\n");
assistantLineOpen = false;
}
process.stdout.write(`\r\x1b[2K${text}\n`);
}
function restorePromptIfIdle(): void {
if (!runActive && !busy) readline.prompt(true);
}
function handleEvent(event: ServerEvent): void {
switch (event.type) {
case "run_start":
runActive = true;
process.stdout.write("\r\x1b[2K");
break;
case "run_end":
runActive = false;
restorePromptIfIdle();
break;
case "assistant_start":
if (assistantLineOpen) process.stdout.write("\n");
process.stdout.write("\r\x1b[2K\x1b[36mPi ›\x1b[0m ");
assistantLineOpen = true;
break;
case "assistant_delta":
process.stdout.write(event.text);
break;
case "assistant_end":
if (assistantLineOpen) process.stdout.write("\n");
assistantLineOpen = false;
break;
case "tool_start":
printLine(`\x1b[2m[tool: ${event.name}]\x1b[0m`);
break;
case "tool_end":
break;
case "error":
printLine(`\x1b[31m${event.message}\x1b[0m`);
restorePromptIfIdle();
break;
default:
break;
}
}
class TuiClientBindings extends RpcTarget implements ClientBindingApi {
async event(event: ServerEvent): Promise<void> {
handleEvent(event);
}
async notify(input: NotifyInput): Promise<NotifyOutput> {
if (!Value.Check(notifyBinding.input, input)) throw new Error("Invalid notify input");
printLine(`\x1b[33m${input.message}\x1b[0m`);
restorePromptIfIdle();
return { displayed: true };
}
}
async function awaitRpc(call: RpcPromise<void>): Promise<void> {
try {
await call;
} finally {
call[Symbol.dispose]();
}
}
const server = newWebSocketRpcSession<ServerApi>(rpcUrl.toString(), new TuiClientBindings(), {
limits: { maxMessageSize: 64 * 1024 },
});
server.onRpcBroken((error: unknown) => {
printLine(`\x1b[31mRPC connection closed: ${error instanceof Error ? error.message : String(error)}\x1b[0m`);
});
await awaitRpc(
server.connect({
clientId,
protocolVersion,
bindings: [{ id: notifyBinding.id, version: notifyBinding.version }],
}),
);
console.log(`\x1b[2mConnected as ${clientId}. Use /notify <message> to invoke the binding without the LLM.\x1b[0m`);
readline.prompt();
readline.on("line", (line) => {
const text = line.trim();
if (!text) {
readline.prompt();
return;
}
if (busy) {
printLine("\x1b[33mThe shared session is busy.\x1b[0m");
return;
}
busy = true;
const operation = text.startsWith("/notify ")
? server.command("notify", { message: text.slice("/notify ".length) })
: server.prompt(text);
void awaitRpc(operation)
.catch((error: unknown) => printLine(`\x1b[31m${error instanceof Error ? error.message : String(error)}\x1b[0m`))
.finally(() => {
busy = false;
restorePromptIfIdle();
});
});
readline.once("close", () => {
server[Symbol.dispose]();
process.stdout.write("\n");
});

View file

@ -0,0 +1,171 @@
import { newWebSocketRpcSession, RpcTarget } from "/vendor/capnweb.js";
const protocolVersion = 1;
const notifyBinding = { id: "pi.notify", version: 1 };
const clientId = sessionStorage.getItem("pi-capnweb-client") ?? `web-${crypto.randomUUID()}`;
sessionStorage.setItem("pi-capnweb-client", clientId);
const connection = document.querySelector(".connection");
const connectionLabel = document.querySelector("#connection-label");
const transcript = document.querySelector("#transcript");
const emptyState = document.querySelector("#empty-state");
const form = document.querySelector("#prompt-form");
const prompt = document.querySelector("#prompt");
const send = document.querySelector("#send");
const testNotify = document.querySelector("#test-notify");
const notification = document.querySelector("#notification");
let connected = false;
let busy = false;
let assistantContent;
let notificationTimer;
function setConnection(state, label) {
connection.dataset.state = state;
connectionLabel.textContent = label;
connected = state === "connected";
send.disabled = !connected || busy;
testNotify.disabled = !connected || busy;
}
function appendMessage(role, text, className = role.toLowerCase()) {
emptyState?.remove();
const message = document.createElement("article");
message.className = `message ${className}`;
const label = document.createElement("p");
label.className = "role";
label.textContent = role;
const content = document.createElement("p");
content.className = "content";
content.textContent = text;
message.append(label, content);
transcript.append(message);
transcript.scrollTop = transcript.scrollHeight;
return content;
}
function showNotification(message) {
clearTimeout(notificationTimer);
notification.hidden = false;
notification.textContent = message;
notification.classList.remove("is-flying");
void notification.offsetWidth;
notification.classList.add("is-flying");
notificationTimer = setTimeout(() => {
notification.hidden = true;
notification.classList.remove("is-flying");
}, 2400);
}
function handleEvent(event) {
switch (event.type) {
case "ready":
setConnection("connected", "Binding online");
break;
case "user_prompt":
appendMessage(event.clientId === clientId ? "You" : "Peer", event.text, "user");
break;
case "assistant_start":
assistantContent = undefined;
break;
case "assistant_delta":
if (!assistantContent) assistantContent = appendMessage("Pi", "", "assistant");
assistantContent.textContent += event.text;
transcript.scrollTop = transcript.scrollHeight;
break;
case "assistant_end":
assistantContent = undefined;
break;
case "tool_start":
appendMessage("Tool", event.name, "tool");
break;
case "error":
appendMessage("Error", event.message, "error");
break;
}
}
class BrowserClientBindings extends RpcTarget {
async event(event) {
handleEvent(event);
}
async notify(input) {
if (typeof input?.message !== "string" || input.message.length === 0) {
throw new Error("Invalid notify input");
}
showNotification(input.message);
return { displayed: true };
}
}
async function awaitRpc(call) {
try {
return await call;
} finally {
call[Symbol.dispose]();
}
}
const rpcUrl = new URL("/api/rpc", window.location.href);
rpcUrl.protocol = rpcUrl.protocol === "https:" ? "wss:" : "ws:";
const server = newWebSocketRpcSession(rpcUrl.toString(), new BrowserClientBindings(), {
limits: { maxMessageSize: 64 * 1024 },
});
server.onRpcBroken((error) => {
setConnection("error", "Disconnected");
appendMessage("Error", error instanceof Error ? error.message : String(error), "error");
});
try {
await awaitRpc(
server.connect({
clientId,
protocolVersion,
bindings: [notifyBinding],
}),
);
} catch (error) {
setConnection("error", error instanceof Error ? error.message : String(error));
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
const text = prompt.value.trim();
if (!text || busy) return;
busy = true;
setConnection("connected", "Pi is working");
prompt.value = "";
try {
await awaitRpc(server.prompt(text));
} catch (error) {
appendMessage("Error", error instanceof Error ? error.message : String(error), "error");
} finally {
busy = false;
setConnection("connected", "Binding online");
prompt.focus();
}
});
prompt.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
form.requestSubmit();
}
});
testNotify.addEventListener("click", async () => {
if (busy) return;
busy = true;
setConnection("connected", "Invoking binding");
try {
await awaitRpc(server.command("notify", { message: "Browser binding confirmed." }));
} catch (error) {
appendMessage("Error", error instanceof Error ? error.message : String(error), "error");
} finally {
busy = false;
setConnection("connected", "Binding online");
}
});
window.addEventListener("beforeunload", () => server[Symbol.dispose]());

View file

@ -0,0 +1,51 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light" />
<link rel="icon" href="data:," />
<title>Pi Cap'n Web capability spike</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<div class="page-grid" aria-hidden="true"></div>
<header class="masthead">
<div>
<p class="eyebrow">Client binding / Cap'n Web</p>
<h1>Pi, called remotely.</h1>
</div>
<div class="connection" data-state="connecting">
<span class="connection-dot"></span>
<span id="connection-label">Connecting</span>
</div>
</header>
<main>
<section class="transcript" id="transcript" aria-live="polite" aria-label="Conversation">
<div class="empty-state" id="empty-state">
<p class="index">01</p>
<p>Ask Pi to notify you, or call the client binding directly.</p>
</div>
</section>
<form class="composer" id="prompt-form">
<label for="prompt">Prompt</label>
<div class="input-row">
<textarea id="prompt" name="prompt" rows="2" placeholder="Notify me through Cap'n Web" required></textarea>
<button type="submit" id="send" disabled>Send</button>
</div>
<div class="composer-meta">
<span>Enter to send · Shift+Enter for newline</span>
<button type="button" class="text-button" id="test-notify" disabled>Call notify binding</button>
</div>
</form>
</main>
<div class="notification-stage" aria-live="assertive" aria-atomic="true">
<div class="notification" id="notification" hidden></div>
</div>
<script type="module" src="/app.js"></script>
</body>
</html>

View file

@ -0,0 +1,376 @@
@layer reset, base, components, motion;
@layer reset {
*,
*::before,
*::after {
box-sizing: border-box;
}
body,
h1,
p {
margin: 0;
}
button,
textarea {
font: inherit;
}
}
@layer base {
:root {
--paper: oklch(0.96 0.025 86);
--ink: oklch(0.19 0.025 54);
--muted: oklch(0.49 0.04 70);
--rule: oklch(0.78 0.055 76);
--signal: oklch(0.78 0.18 77);
--hot: oklch(0.59 0.23 34);
--success: oklch(0.55 0.13 151);
font-family: "Avenir Next", "Century Gothic", sans-serif;
color: var(--ink);
background: var(--paper);
}
body {
min-block-size: 100svh;
display: grid;
grid-template-rows: auto 1fr;
padding: clamp(1rem, 3vw, 2.75rem);
overflow-x: hidden;
}
button,
textarea {
color: inherit;
}
button:focus-visible,
textarea:focus-visible {
outline: 3px solid var(--hot);
outline-offset: 3px;
}
.page-grid {
position: fixed;
inset: 0;
z-index: -1;
background-image:
linear-gradient(to right, color-mix(in oklch, var(--rule) 34%, transparent) 1px, transparent 1px),
linear-gradient(to bottom, color-mix(in oklch, var(--rule) 25%, transparent) 1px, transparent 1px);
background-size: 2rem 2rem;
mask-image: linear-gradient(to bottom, black, transparent 78%);
}
}
@layer components {
.masthead {
display: flex;
align-items: start;
justify-content: space-between;
gap: 2rem;
padding-block-end: clamp(2rem, 7vh, 5rem);
& h1 {
max-inline-size: 15ch;
font-family: "Iowan Old Style", "Palatino Linotype", Georgia, serif;
font-size: clamp(2.75rem, 8vw, 7.5rem);
font-weight: 600;
font-style: italic;
letter-spacing: -0.065em;
line-height: 0.84;
text-wrap: balance;
}
}
.eyebrow,
.connection,
.composer label,
.composer-meta {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.eyebrow {
margin-block-end: 1rem;
color: var(--hot);
}
.connection {
display: flex;
align-items: center;
gap: 0.55rem;
padding-block: 0.4rem;
white-space: nowrap;
& .connection-dot {
inline-size: 0.65rem;
block-size: 0.65rem;
border: 1px solid currentColor;
border-radius: 50%;
background: var(--signal);
}
&[data-state="connected"] .connection-dot {
background: var(--success);
}
&[data-state="error"] .connection-dot {
background: var(--hot);
}
}
main {
inline-size: min(100%, 68rem);
margin-inline-start: auto;
display: grid;
grid-template-rows: minmax(14rem, 1fr) auto;
gap: clamp(2rem, 5vh, 4rem);
}
.transcript {
align-self: end;
max-block-size: 42vh;
overflow-y: auto;
scrollbar-gutter: stable;
display: grid;
gap: 1.2rem;
padding-inline: 0.25rem 1rem;
}
.empty-state {
display: grid;
grid-template-columns: 4rem minmax(0, 26rem);
gap: 1rem;
align-items: start;
color: var(--muted);
font-family: "Iowan Old Style", "Palatino Linotype", Georgia, serif;
font-size: clamp(1.15rem, 2.5vw, 1.65rem);
line-height: 1.25;
& .index {
font-family: "Avenir Next", "Century Gothic", sans-serif;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.12em;
}
}
.message {
display: grid;
grid-template-columns: 4rem minmax(0, 42rem);
gap: 1rem;
line-height: 1.55;
white-space: pre-wrap;
& .role {
padding-block-start: 0.25rem;
color: var(--muted);
font-size: 0.67rem;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
}
&.user .content {
font-family: "Iowan Old Style", "Palatino Linotype", Georgia, serif;
font-size: 1.2rem;
}
&.tool .content {
color: var(--muted);
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
}
.composer {
border-block-start: 2px solid var(--ink);
padding-block-start: 0.75rem;
& label {
display: block;
margin-block-end: 0.6rem;
}
}
.input-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.75rem;
align-items: stretch;
}
textarea {
min-block-size: 5.5rem;
resize: vertical;
border: 0;
background: transparent;
padding: 0.65rem 0;
font-family: "Iowan Old Style", "Palatino Linotype", Georgia, serif;
font-size: clamp(1.15rem, 2.2vw, 1.55rem);
line-height: 1.35;
&::placeholder {
color: color-mix(in oklch, var(--muted) 65%, transparent);
}
}
button {
border: 0;
cursor: pointer;
}
#send {
min-inline-size: 6.5rem;
background: var(--ink);
color: var(--paper);
font-size: 0.8rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
transition: transform 160ms ease-out, background-color 160ms ease-out;
&:hover:not(:disabled) {
background: var(--hot);
transform: translateY(-2px);
}
&:disabled {
cursor: not-allowed;
opacity: 0.35;
}
}
.composer-meta {
display: flex;
justify-content: space-between;
gap: 1rem;
margin-block-start: 0.75rem;
color: var(--muted);
}
.text-button {
padding: 0;
background: transparent;
color: var(--hot);
font-weight: 800;
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 0.25rem;
&:disabled {
cursor: not-allowed;
opacity: 0.4;
}
}
.notification-stage {
position: fixed;
inset: 0;
z-index: 10;
pointer-events: none;
overflow: hidden;
}
.notification {
position: absolute;
inset-block-start: 50%;
inset-inline-start: 50%;
max-inline-size: min(80vw, 46rem);
padding: clamp(1rem, 3vw, 2rem) clamp(1.25rem, 5vw, 3.5rem);
background: var(--signal);
color: var(--ink);
box-shadow: 0.8rem 0.8rem 0 var(--hot);
font-family: "Iowan Old Style", "Palatino Linotype", Georgia, serif;
font-size: clamp(1.75rem, 6vw, 5rem);
font-weight: 700;
font-style: italic;
line-height: 0.95;
text-align: center;
text-wrap: balance;
}
@media (width < 44rem) {
body {
padding: 1rem;
}
.masthead {
align-items: end;
padding-block-end: 3rem;
}
.connection {
& #connection-label {
display: none;
}
}
.message,
.empty-state {
grid-template-columns: 2.5rem minmax(0, 1fr);
}
.input-row {
grid-template-columns: 1fr;
}
#send {
min-block-size: 3.25rem;
}
.composer-meta {
align-items: start;
flex-direction: column;
}
}
}
@layer motion {
.notification.is-flying {
animation: notify-spin-out 2.35s cubic-bezier(0.77, 0, 0.22, 1) both;
}
@keyframes notify-spin-out {
0% {
opacity: 0;
transform: translate(-50%, -50%) rotate(-18deg) scale(0.35);
}
13% {
opacity: 1;
transform: translate(-50%, -50%) rotate(2deg) scale(1.08);
}
25%,
52% {
opacity: 1;
transform: translate(-50%, -50%) rotate(0deg) scale(1);
}
100% {
opacity: 0;
transform: translate(calc(-50% + 112vw), calc(-50% - 82vh)) rotate(1600deg) scale(0.08);
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
}
.notification.is-flying {
animation: notify-fade 2.35s ease-out both;
}
@keyframes notify-fade {
0%, 100% { opacity: 0; transform: translate(-50%, -50%); }
15%, 75% { opacity: 1; transform: translate(-50%, -50%); }
}
}
}

View file

@ -0,0 +1,69 @@
import type { RpcTransport } from "capnweb";
import type WebSocket from "ws";
interface Receiver {
resolve(message: string): void;
reject(error: Error): void;
}
/** Adapts the Node `ws` server socket to Cap'n Web without an unsafe type cast. */
export class NodeWebSocketTransport implements RpcTransport {
readonly #socket: WebSocket;
readonly #messages: string[] = [];
readonly #receivers: Receiver[] = [];
#closedError: Error | undefined;
constructor(socket: WebSocket) {
this.#socket = socket;
socket.on("message", (data, isBinary) => {
if (isBinary) {
this.abort(new Error("Binary Cap'n Web frames are not supported"));
return;
}
this.#deliver(data.toString());
});
socket.once("close", () => this.#fail(new Error("WebSocket closed")));
socket.once("error", (error) => this.#fail(error));
}
send(message: string): Promise<void> {
if (this.#closedError || this.#socket.readyState !== 1) {
return Promise.reject(this.#closedError ?? new Error("WebSocket is not open"));
}
return new Promise<void>((resolve, reject) => {
this.#socket.send(message, (error) => {
if (error) reject(error);
else resolve();
});
});
}
receive(): Promise<string> {
const message = this.#messages.shift();
if (message !== undefined) return Promise.resolve(message);
if (this.#closedError) return Promise.reject(this.#closedError);
return new Promise<string>((resolve, reject) => {
this.#receivers.push({ resolve, reject });
});
}
abort(reason: unknown): void {
const error = reason instanceof Error ? reason : new Error(String(reason));
this.#fail(error);
if (this.#socket.readyState === 0 || this.#socket.readyState === 1) {
this.#socket.close(1011, "RPC session aborted");
}
}
#deliver(message: string): void {
const receiver = this.#receivers.shift();
if (receiver) receiver.resolve(message);
else this.#messages.push(message);
}
#fail(error: Error): void {
if (this.#closedError) return;
this.#closedError = error;
for (const receiver of this.#receivers.splice(0)) receiver.reject(error);
}
}

View file

@ -0,0 +1,79 @@
# Client capability spike
A deliberately small server/client spike built on the new `AgentHarness` API. It demonstrates that a server extension can invoke one semantic client action without knowing whether the originating client is a TUI or a browser.
## Architecture
```text
notify extension ─┬─ notify tool ───────┐
├─ notify command ────┤
└─ response hook ─────┼─ pi.notify@1 ── originating client
│
AgentHarness ─── shared in-memory session
```
The clients dynamically advertise `pi.notify@1` after connecting. Registrations are scoped to the SSE connection and disappear on disconnect. The model-facing tool, direct command, and assistant-response event hook call the same extension function and capability registry.
The transport is intentionally plain HTTP plus server-sent events:
- SSE carries session events and server-to-client action requests.
- HTTP requests carry prompts, capability registration, and action results.
- The server also serves the static web client, so a Cloudflare Worker is unnecessary for this local spike.
This only prototypes the capability boundary. Authentication, persistent sessions, client-controller leases, reconnect replay, and a production protocol are intentionally omitted.
## Run
From the repository root:
```bash
OPENAI_API_KEY=... npx tsx packages/coding-agent/examples/capability-spike/server.ts
```
The default model is `openai/gpt-5.4-nano`. Select another registered OpenAI or Anthropic model with:
```bash
PI_SPIKE_MODEL=anthropic/claude-haiku-4-5 ANTHROPIC_API_KEY=... \
npx tsx packages/coding-agent/examples/capability-spike/server.ts
```
The server starts without provider credentials, which is enough to exercise direct capability calls. LLM prompts require the selected provider's environment credential.
Open [http://127.0.0.1:8787](http://127.0.0.1:8787) for the web client.
In another terminal, run the TUI client:
```bash
npx tsx packages/coding-agent/examples/capability-spike/tui.ts
```
Try:
```text
/notify TUI capability confirmed.
```
Or prompt the model:
```text
Notify me that the capability RPC works, then briefly explain what happened.
```
When both clients are connected, a notification requested by a prompt is delivered only to the client that submitted that prompt.
For a deterministic extension-initiated action that does not use the tool wrapper, ask:
```text
What is the capital of France?
```
The server system prompt makes the assistant answer exactly `Paris`. The extension observes the completed assistant message and invokes `pi.notify@1` with `❤️` on the originating client.
## Files
- `server.ts`: HTTP/SSE server and `AgentHarness` session
- `extension.ts`: server extension with the command, model-facing tool, and assistant-response hook
- `capability-registry.ts`: connection-scoped action registration and invocation
- `protocol.ts`: typed `pi.notify@1` input/output contract and wire events
- `tui.ts`: readline client whose notify implementation prints yellow text
- `web/`: static browser client with the spinning notification animation

View file

@ -0,0 +1,190 @@
import { randomUUID } from "node:crypto";
import type { Static, TSchema } from "typebox";
import { Value } from "typebox/value";
import type { ActionRegistration, ActionResultMessage, ClientAction, ServerEvent } from "./protocol.ts";
interface ClientConnection {
id: string;
actions: Map<string, number>;
send(event: ServerEvent): void;
}
interface PendingInvocation {
clientId: string;
action: ClientAction<TSchema, TSchema>;
resolve(value: unknown): void;
reject(error: Error): void;
timer: NodeJS.Timeout;
removeAbortListener?: () => void;
}
export class CapabilityError extends Error {
public code:
| "client_unavailable"
| "action_unavailable"
| "invalid_input"
| "invalid_output"
| "aborted"
| "timeout"
| "remote_error";
constructor(
code:
| "client_unavailable"
| "action_unavailable"
| "invalid_input"
| "invalid_output"
| "aborted"
| "timeout"
| "remote_error",
message: string,
) {
super(message);
this.name = "CapabilityError";
this.code = code;
}
}
export class CapabilityRegistry {
private readonly clients = new Map<string, ClientConnection>();
private readonly pending = new Map<string, PendingInvocation>();
connect(clientId: string, send: (event: ServerEvent) => void): () => void {
this.disconnect(clientId, "Client reconnected");
const connection: ClientConnection = { id: clientId, actions: new Map(), send };
this.clients.set(clientId, connection);
connection.send({ type: "ready", clientId });
return () => {
if (this.clients.get(clientId) === connection) {
this.disconnect(clientId, "Client disconnected");
}
};
}
hasClient(clientId: string): boolean {
return this.clients.has(clientId);
}
register(clientId: string, actions: ActionRegistration[]): void {
const client = this.clients.get(clientId);
if (!client) {
throw new CapabilityError("client_unavailable", `Client ${clientId} is not connected`);
}
client.actions = new Map(actions.map((action) => [action.id, action.version]));
client.send({ type: "capabilities_updated", actions });
}
async invoke<TInputSchema extends TSchema, TOutputSchema extends TSchema>(
clientId: string,
action: ClientAction<TInputSchema, TOutputSchema>,
input: unknown,
options: { signal?: AbortSignal; timeoutMs?: number } = {},
): Promise<Static<TOutputSchema>> {
if (!Value.Check(action.input, input)) {
throw new CapabilityError("invalid_input", `Input does not match ${action.id}@${action.version}`);
}
const client = this.clients.get(clientId);
if (!client) {
throw new CapabilityError("client_unavailable", `Client ${clientId} is not connected`);
}
if (client.actions.get(action.id) !== action.version) {
throw new CapabilityError(
"action_unavailable",
`Client ${clientId} does not provide ${action.id}@${action.version}`,
);
}
if (options.signal?.aborted) {
throw new CapabilityError("aborted", `${action.id} was aborted`);
}
const requestId = randomUUID();
const timeoutMs = options.timeoutMs ?? 10_000;
const result = new Promise<unknown>((resolve, reject) => {
const timer = setTimeout(() => {
this.rejectInvocation(
requestId,
new CapabilityError("timeout", `${action.id} timed out after ${timeoutMs}ms`),
);
client.send({ type: "action_cancel", requestId });
}, timeoutMs);
const pending: PendingInvocation = {
clientId,
action,
resolve,
reject,
timer,
};
if (options.signal) {
const onAbort = () => {
this.rejectInvocation(requestId, new CapabilityError("aborted", `${action.id} was aborted`));
client.send({ type: "action_cancel", requestId });
};
options.signal.addEventListener("abort", onAbort, { once: true });
pending.removeAbortListener = () => options.signal?.removeEventListener("abort", onAbort);
}
this.pending.set(requestId, pending);
});
client.send({
type: "action_request",
requestId,
action: { id: action.id, version: action.version },
input,
});
const output = await result;
if (!Value.Check(action.output, output)) {
throw new CapabilityError("invalid_output", `Output does not match ${action.id}@${action.version}`);
}
return output;
}
handleResult(requestId: string, message: ActionResultMessage): void {
const pending = this.pending.get(requestId);
if (!pending) return;
if (pending.clientId !== message.clientId) {
throw new CapabilityError("remote_error", `Client ${message.clientId} cannot answer request ${requestId}`);
}
this.pending.delete(requestId);
clearTimeout(pending.timer);
pending.removeAbortListener?.();
if (!message.ok) {
pending.reject(new CapabilityError("remote_error", message.error || `${pending.action.id} failed`));
return;
}
if (!Value.Check(pending.action.output, message.result)) {
pending.reject(
new CapabilityError(
"invalid_output",
`Output does not match ${pending.action.id}@${pending.action.version}`,
),
);
return;
}
pending.resolve(message.result);
}
broadcast(event: ServerEvent): void {
for (const client of this.clients.values()) {
client.send(event);
}
}
private rejectInvocation(requestId: string, error: Error): void {
const pending = this.pending.get(requestId);
if (!pending) return;
this.pending.delete(requestId);
clearTimeout(pending.timer);
pending.removeAbortListener?.();
pending.reject(error);
}
private disconnect(clientId: string, reason: string): void {
if (!this.clients.delete(clientId)) return;
for (const [requestId, pending] of this.pending) {
if (pending.clientId === clientId) {
this.rejectInvocation(requestId, new CapabilityError("client_unavailable", reason));
}
}
}
}

View file

@ -0,0 +1,86 @@
import type { AgentHarnessEvent, AgentHarnessTool } from "@earendil-works/pi-agent-core";
import { contentText } from "@earendil-works/pi-ai";
import { type Static, type TSchema, Type } from "typebox";
import { type NotifyOutput, notifyAction } from "./protocol.ts";
export interface ClientActions {
invoke<TInputSchema extends TSchema, TOutputSchema extends TSchema>(
action: {
id: string;
version: number;
input: TInputSchema;
output: TOutputSchema;
},
input: unknown,
options?: { signal?: AbortSignal; timeoutMs?: number },
): Promise<Static<TOutputSchema>>;
}
export interface NotifyExtensionContext {
clientActions: ClientActions;
}
export type ServerCommand = (input: unknown, context: NotifyExtensionContext, signal?: AbortSignal) => Promise<unknown>;
export type MessageEndEvent = Extract<AgentHarnessEvent, { type: "message_end" }>;
export type MessageEndHandler = (
event: MessageEndEvent,
context: NotifyExtensionContext,
signal?: AbortSignal,
) => Promise<void> | void;
export interface ServerExtensionApi {
registerTool(tool: AgentHarnessTool<NotifyExtensionContext>): void;
registerCommand(name: string, command: ServerCommand): void;
on(type: "message_end", handler: MessageEndHandler): void;
}
async function notifyClient(
input: unknown,
context: NotifyExtensionContext,
signal?: AbortSignal,
): Promise<NotifyOutput> {
if (
typeof input !== "object" ||
input === null ||
!("message" in input) ||
typeof input.message !== "string" ||
input.message.trim().length === 0
) {
throw new Error("notify requires a non-empty message");
}
return context.clientActions.invoke(notifyAction, { message: input.message.trim() }, { signal });
}
const notifyParameters = Type.Object(
{
message: Type.String({ description: "Notification text", minLength: 1, maxLength: 500 }),
},
{ additionalProperties: false },
);
export function notifyExtension(pi: ServerExtensionApi): void {
pi.registerCommand("notify", notifyClient);
pi.on("message_end", async (event, context, signal) => {
if (event.message.role !== "assistant") return;
if (contentText(event.message.content, "").trim() === "Paris") {
await notifyClient({ message: "❤️" }, context, signal);
}
});
pi.registerTool({
name: "notify",
label: "Notify client",
description: "Show a brief notification on the client that submitted the current prompt.",
parameters: notifyParameters,
async execute(_toolCallId, params, signal, _onUpdate, context) {
const input = typeof params === "object" && params !== null ? params : {};
const message = "message" in input && typeof input.message === "string" ? input.message : "";
await notifyClient({ message }, context, signal);
return {
content: [{ type: "text", text: `Notified the originating client: ${message}` }],
details: { displayed: true },
};
},
});
}

View file

@ -0,0 +1,55 @@
import { type Static, type TSchema, Type } from "typebox";
export interface ClientAction<TInputSchema extends TSchema, TOutputSchema extends TSchema> {
id: string;
version: number;
input: TInputSchema;
output: TOutputSchema;
}
export const notifyAction = {
id: "pi.notify",
version: 1,
input: Type.Object(
{
message: Type.String({ minLength: 1, maxLength: 500 }),
},
{ additionalProperties: false },
),
output: Type.Object(
{
displayed: Type.Literal(true),
},
{ additionalProperties: false },
),
} satisfies ClientAction<TSchema, TSchema>;
export type NotifyInput = Static<typeof notifyAction.input>;
export type NotifyOutput = Static<typeof notifyAction.output>;
export interface ActionRegistration {
id: string;
version: number;
}
export type ServerEvent =
| { type: "ready"; clientId: string }
| { type: "capabilities_updated"; actions: ActionRegistration[] }
| { type: "user_prompt"; clientId: string; text: string }
| { type: "run_start" }
| { type: "run_end" }
| { type: "assistant_start" }
| { type: "assistant_delta"; text: string }
| { type: "assistant_end" }
| { type: "tool_start"; name: string }
| { type: "tool_end"; name: string; isError: boolean }
| { type: "error"; message: string }
| { type: "action_request"; requestId: string; action: ActionRegistration; input: unknown }
| { type: "action_cancel"; requestId: string };
export interface ActionResultMessage {
clientId: string;
ok: boolean;
result?: unknown;
error?: string;
}

View file

@ -0,0 +1,317 @@
import { readFile } from "node:fs/promises";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
AgentHarness,
type AgentHarnessEvent,
type AgentHarnessTool,
InMemorySessionStorage,
Session,
} from "@earendil-works/pi-agent-core";
import { createModels, type Model } from "@earendil-works/pi-ai";
import { anthropicProvider } from "@earendil-works/pi-ai/providers/anthropic";
import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";
import { CapabilityError, CapabilityRegistry } from "./capability-registry.ts";
import {
type MessageEndHandler,
type NotifyExtensionContext,
notifyExtension,
type ServerCommand,
type ServerExtensionApi,
} from "./extension.ts";
import type { ActionRegistration, ActionResultMessage, ServerEvent } from "./protocol.ts";
const host = process.env.PI_SPIKE_HOST ?? "127.0.0.1";
const port = Number(process.env.PI_SPIKE_PORT ?? "8787");
const modelSpec = process.env.PI_SPIKE_MODEL ?? "openai/gpt-5.4-nano";
const [providerId, modelId] = modelSpec.split("/", 2);
if (!providerId || !modelId) {
throw new Error(`PI_SPIKE_MODEL must use provider/model syntax, received ${modelSpec}`);
}
const models = createModels();
models.setProvider(openaiProvider());
models.setProvider(anthropicProvider());
function createFallbackModel(provider: string, id: string): Model<"openai-responses" | "anthropic-messages"> {
const common = {
id,
name: id,
reasoning: false,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 16_384,
};
if (provider === "openai") {
return {
...common,
api: "openai-responses",
provider: "openai",
baseUrl: "https://api.openai.com/v1",
input: ["text"],
};
}
if (provider === "anthropic") {
return {
...common,
api: "anthropic-messages",
provider: "anthropic",
baseUrl: "https://api.anthropic.com",
input: ["text"],
};
}
throw new Error(`Unsupported provider ${provider}. This spike registers openai and anthropic.`);
}
const model = models.getModel(providerId, modelId) ?? createFallbackModel(providerId, modelId);
const registry = new CapabilityRegistry();
const tools: AgentHarnessTool<NotifyExtensionContext>[] = [];
const commands = new Map<string, ServerCommand>();
const messageEndHandlers: MessageEndHandler[] = [];
const extensionApi: ServerExtensionApi = {
registerTool(tool) {
tools.push(tool);
},
registerCommand(name, command) {
commands.set(name, command);
},
on(type, handler) {
if (type === "message_end") messageEndHandlers.push(handler);
},
};
notifyExtension(extensionApi);
let activeOriginClientId: string | undefined;
let promptInFlight = false;
function contextFor(clientId: string): NotifyExtensionContext {
return {
clientActions: {
invoke: (action, input, options) => registry.invoke(clientId, action, input, options),
},
};
}
const harness = new AgentHarness({
session: new Session(new InMemorySessionStorage()),
models,
model,
thinkingLevel: "off",
tools,
toolContext: () => {
if (!activeOriginClientId) throw new Error("No originating client is bound to this turn");
return contextFor(activeOriginClientId);
},
systemPrompt: [
"You are a concise assistant.",
"When asked for the capital of France, respond with exactly Paris and nothing else.",
"When the user asks for a notification, call the notify tool with the exact message they want shown.",
].join(" "),
});
harness.subscribe(async (event, signal) => {
for (const wireEvent of toServerEvents(event)) registry.broadcast(wireEvent);
if (event.type === "message_end" && messageEndHandlers.length > 0) {
if (!activeOriginClientId) throw new Error("No originating client is bound to this assistant response");
const context = contextFor(activeOriginClientId);
for (const handler of messageEndHandlers) await handler(event, context, signal);
}
});
function toServerEvents(event: AgentHarnessEvent): ServerEvent[] {
switch (event.type) {
case "agent_start":
return [{ type: "run_start" }];
case "agent_end":
return [{ type: "run_end" }];
case "message_start":
return event.message.role === "assistant" ? [{ type: "assistant_start" }] : [];
case "message_update":
return event.assistantMessageEvent.type === "text_delta"
? [{ type: "assistant_delta", text: event.assistantMessageEvent.delta }]
: [];
case "message_end":
if (event.message.role !== "assistant") return [];
return [
{ type: "assistant_end" },
...(event.message.errorMessage ? [{ type: "error" as const, message: event.message.errorMessage }] : []),
];
case "tool_execution_start":
return [{ type: "tool_start", name: event.toolName }];
case "tool_execution_end":
return [{ type: "tool_end", name: event.toolName, isError: event.isError }];
default:
return [];
}
}
function sendJson(response: ServerResponse, status: number, body: unknown): void {
response.writeHead(status, {
"content-type": "application/json; charset=utf-8",
"cache-control": "no-store",
});
response.end(JSON.stringify(body));
}
async function readJson(request: IncomingMessage): Promise<unknown> {
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of request) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
size += buffer.length;
if (size > 64 * 1024) throw new Error("Request body exceeds 64 KiB");
chunks.push(buffer);
}
if (chunks.length === 0) return {};
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
}
function objectRecord(value: unknown): Record<string, unknown> | undefined {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function requireClientId(value: unknown): string {
if (typeof value !== "string" || !/^[a-zA-Z0-9_-]{1,80}$/.test(value)) {
throw new Error("Invalid clientId");
}
return value;
}
function parseActions(value: unknown): ActionRegistration[] {
if (!Array.isArray(value)) throw new Error("actions must be an array");
return value.map((candidate) => {
const action = objectRecord(candidate);
if (!action || typeof action.id !== "string" || typeof action.version !== "number") {
throw new Error("Invalid action registration");
}
return { id: action.id, version: action.version };
});
}
function errorStatus(error: unknown): number {
if (!(error instanceof CapabilityError)) return 400;
return error.code === "client_unavailable" || error.code === "action_unavailable" ? 409 : 400;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
const webRoot = join(dirname(fileURLToPath(import.meta.url)), "web");
const staticFiles = new Map([
["/", { path: join(webRoot, "index.html"), type: "text/html; charset=utf-8" }],
["/app.js", { path: join(webRoot, "app.js"), type: "text/javascript; charset=utf-8" }],
["/styles.css", { path: join(webRoot, "styles.css"), type: "text/css; charset=utf-8" }],
]);
const server = createServer(async (request, response) => {
try {
const url = new URL(request.url ?? "/", `http://${request.headers.host ?? `${host}:${port}`}`);
const staticFile = request.method === "GET" ? staticFiles.get(url.pathname) : undefined;
if (staticFile) {
response.writeHead(200, { "content-type": staticFile.type, "cache-control": "no-store" });
response.end(await readFile(staticFile.path));
return;
}
if (request.method === "GET" && url.pathname === "/api/events") {
const clientId = requireClientId(url.searchParams.get("clientId"));
response.writeHead(200, {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache, no-transform",
connection: "keep-alive",
});
response.flushHeaders();
const disconnect = registry.connect(clientId, (event) => {
response.write(`data: ${JSON.stringify(event)}\n\n`);
});
const heartbeat = setInterval(() => response.write(": heartbeat\n\n"), 15_000);
request.once("close", () => {
clearInterval(heartbeat);
disconnect();
});
return;
}
if (
request.method === "PUT" &&
url.pathname.startsWith("/api/clients/") &&
url.pathname.endsWith("/capabilities")
) {
const clientId = requireClientId(url.pathname.slice("/api/clients/".length, -"/capabilities".length));
const body = objectRecord(await readJson(request));
registry.register(clientId, parseActions(body?.actions));
sendJson(response, 200, { ok: true });
return;
}
if (request.method === "POST" && url.pathname.startsWith("/api/action-results/")) {
const requestId = url.pathname.slice("/api/action-results/".length);
const body = objectRecord(await readJson(request));
const message: ActionResultMessage = {
clientId: requireClientId(body?.clientId),
ok: body?.ok === true,
result: body?.result,
error: typeof body?.error === "string" ? body.error : undefined,
};
registry.handleResult(requestId, message);
sendJson(response, 200, { ok: true });
return;
}
if (request.method === "POST" && url.pathname === "/api/prompt") {
const body = objectRecord(await readJson(request));
const clientId = requireClientId(body?.clientId);
if (!registry.hasClient(clientId)) throw new CapabilityError("client_unavailable", "Client is not connected");
if (typeof body?.text !== "string" || body.text.trim().length === 0) throw new Error("Prompt is required");
if (promptInFlight) {
sendJson(response, 409, { ok: false, error: "The shared session is already processing a prompt" });
return;
}
promptInFlight = true;
activeOriginClientId = clientId;
registry.broadcast({ type: "user_prompt", clientId, text: body.text.trim() });
try {
const assistant = await harness.prompt(body.text.trim());
sendJson(response, 200, { ok: assistant.stopReason !== "error", error: assistant.errorMessage });
} finally {
activeOriginClientId = undefined;
promptInFlight = false;
}
return;
}
if (request.method === "POST" && url.pathname.startsWith("/api/commands/")) {
const name = url.pathname.slice("/api/commands/".length);
const command = commands.get(name);
if (!command) {
sendJson(response, 404, { ok: false, error: `Unknown command ${name}` });
return;
}
const body = objectRecord(await readJson(request));
const clientId = requireClientId(body?.clientId);
const result = await command(body, contextFor(clientId));
sendJson(response, 200, { ok: true, result });
return;
}
sendJson(response, 404, { ok: false, error: "Not found" });
} catch (error) {
sendJson(response, errorStatus(error), { ok: false, error: errorMessage(error) });
}
});
server.listen(port, host, async () => {
const auth = await models.checkAuth(providerId);
console.log(`Capability spike listening at http://${host}:${port}`);
console.log(`Model: ${modelSpec}${auth ? ` (${auth.source})` : " (provider auth not configured)"}`);
console.log("Open the web URL or run: npx tsx packages/coding-agent/examples/capability-spike/tui.ts");
});
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.once(signal, () => {
server.close(() => process.exit(0));
});
}

View file

@ -0,0 +1,182 @@
import { randomUUID } from "node:crypto";
import { createInterface } from "node:readline";
import type { ActionResultMessage, ServerEvent } from "./protocol.ts";
import { notifyAction } from "./protocol.ts";
const baseUrl = process.env.PI_SPIKE_URL ?? "http://127.0.0.1:8787";
const clientId = `tui-${randomUUID()}`;
const eventsAbort = new AbortController();
interface JsonResponse {
ok?: boolean;
error?: string;
}
async function request(path: string, init: RequestInit): Promise<unknown> {
const response = await fetch(`${baseUrl}${path}`, {
...init,
headers: { "content-type": "application/json", ...init.headers },
});
const body = (await response.json()) as JsonResponse;
if (!response.ok || body.ok === false) throw new Error(body.error ?? `Request failed with ${response.status}`);
return body;
}
function isServerEvent(value: unknown): value is ServerEvent {
return typeof value === "object" && value !== null && "type" in value && typeof value.type === "string";
}
const eventResponse = await fetch(`${baseUrl}/api/events?clientId=${encodeURIComponent(clientId)}`, {
headers: { accept: "text/event-stream" },
signal: eventsAbort.signal,
});
if (!eventResponse.ok || !eventResponse.body) {
throw new Error(`Could not connect to ${baseUrl}: ${eventResponse.status} ${eventResponse.statusText}`);
}
await request(`/api/clients/${encodeURIComponent(clientId)}/capabilities`, {
method: "PUT",
body: JSON.stringify({ actions: [{ id: notifyAction.id, version: notifyAction.version }] }),
});
const readline = createInterface({ input: process.stdin, output: process.stdout, prompt: "\x1b[1mYou ›\x1b[0m " });
let assistantLineOpen = false;
let busy = false;
let runActive = false;
function printLine(text: string): void {
if (assistantLineOpen) {
process.stdout.write("\n");
assistantLineOpen = false;
}
process.stdout.write(`\r\x1b[2K${text}\n`);
}
function restorePromptIfIdle(): void {
if (!runActive && !busy) readline.prompt(true);
}
async function answerAction(event: Extract<ServerEvent, { type: "action_request" }>): Promise<void> {
if (event.action.id !== notifyAction.id || event.action.version !== notifyAction.version) {
await postActionResult(event.requestId, { clientId, ok: false, error: "Unsupported action" });
return;
}
const input = typeof event.input === "object" && event.input !== null ? event.input : undefined;
const message = input && "message" in input && typeof input.message === "string" ? input.message : undefined;
if (!message) {
await postActionResult(event.requestId, { clientId, ok: false, error: "Invalid notify input" });
return;
}
printLine(`\x1b[33m${message}\x1b[0m`);
await postActionResult(event.requestId, { clientId, ok: true, result: { displayed: true } });
restorePromptIfIdle();
}
async function postActionResult(requestId: string, result: ActionResultMessage): Promise<void> {
await request(`/api/action-results/${encodeURIComponent(requestId)}`, {
method: "POST",
body: JSON.stringify(result),
});
}
function handleEvent(event: ServerEvent): void {
switch (event.type) {
case "run_start":
runActive = true;
process.stdout.write("\r\x1b[2K");
break;
case "run_end":
runActive = false;
restorePromptIfIdle();
break;
case "assistant_start":
if (assistantLineOpen) process.stdout.write("\n");
process.stdout.write("\r\x1b[2K\x1b[36mPi ›\x1b[0m ");
assistantLineOpen = true;
break;
case "assistant_delta":
process.stdout.write(event.text);
break;
case "assistant_end":
if (assistantLineOpen) process.stdout.write("\n");
assistantLineOpen = false;
break;
case "tool_start":
printLine(`\x1b[2m[tool: ${event.name}]\x1b[0m`);
break;
case "tool_end":
break;
case "error":
printLine(`\x1b[31m${event.message}\x1b[0m`);
restorePromptIfIdle();
break;
case "action_request":
void answerAction(event).catch((error: unknown) => printLine(`\x1b[31m${String(error)}\x1b[0m`));
break;
default:
break;
}
}
async function consumeEvents(): Promise<void> {
const decoder = new TextDecoder();
let buffer = "";
for await (const chunk of eventResponse.body!) {
buffer += decoder.decode(chunk, { stream: true });
let boundary = buffer.indexOf("\n\n");
while (boundary !== -1) {
const frame = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
for (const line of frame.split("\n")) {
if (!line.startsWith("data: ")) continue;
const parsed: unknown = JSON.parse(line.slice(6));
if (isServerEvent(parsed)) handleEvent(parsed);
}
boundary = buffer.indexOf("\n\n");
}
}
}
void consumeEvents().catch((error: unknown) => {
if (!eventsAbort.signal.aborted) {
printLine(`\x1b[31mEvent stream closed: ${String(error)}\x1b[0m`);
runActive = false;
restorePromptIfIdle();
}
});
console.log(`\x1b[2mConnected as ${clientId}. Use /notify <message> to invoke the capability without the LLM.\x1b[0m`);
readline.prompt();
readline.on("line", (line) => {
const text = line.trim();
if (!text) {
readline.prompt();
return;
}
if (busy) {
printLine("\x1b[33mThe shared session is busy.\x1b[0m");
return;
}
busy = true;
const operation = text.startsWith("/notify ")
? request("/api/commands/notify", {
method: "POST",
body: JSON.stringify({ clientId, message: text.slice("/notify ".length) }),
})
: request("/api/prompt", {
method: "POST",
body: JSON.stringify({ clientId, text }),
});
void operation
.catch((error: unknown) => printLine(`\x1b[31m${error instanceof Error ? error.message : String(error)}\x1b[0m`))
.finally(() => {
busy = false;
restorePromptIfIdle();
});
});
readline.once("close", () => {
eventsAbort.abort();
process.stdout.write("\n");
});

View file

@ -0,0 +1,176 @@
const notifyAction = { id: "pi.notify", version: 1 };
const clientId = sessionStorage.getItem("pi-capability-client") ?? `web-${crypto.randomUUID()}`;
sessionStorage.setItem("pi-capability-client", clientId);
const connection = document.querySelector(".connection");
const connectionLabel = document.querySelector("#connection-label");
const transcript = document.querySelector("#transcript");
const emptyState = document.querySelector("#empty-state");
const form = document.querySelector("#prompt-form");
const prompt = document.querySelector("#prompt");
const send = document.querySelector("#send");
const testNotify = document.querySelector("#test-notify");
const notification = document.querySelector("#notification");
let connected = false;
let busy = false;
let assistantContent;
let notificationTimer;
function setConnection(state, label) {
connection.dataset.state = state;
connectionLabel.textContent = label;
connected = state === "connected";
send.disabled = !connected || busy;
testNotify.disabled = !connected || busy;
}
function appendMessage(role, text, className = role.toLowerCase()) {
emptyState?.remove();
const message = document.createElement("article");
message.className = `message ${className}`;
const label = document.createElement("p");
label.className = "role";
label.textContent = role;
const content = document.createElement("p");
content.className = "content";
content.textContent = text;
message.append(label, content);
transcript.append(message);
transcript.scrollTop = transcript.scrollHeight;
return content;
}
function showNotification(message) {
clearTimeout(notificationTimer);
notification.hidden = false;
notification.textContent = message;
notification.classList.remove("is-flying");
void notification.offsetWidth;
notification.classList.add("is-flying");
notificationTimer = setTimeout(() => {
notification.hidden = true;
notification.classList.remove("is-flying");
}, 2400);
}
async function api(path, options) {
const response = await fetch(path, {
...options,
headers: { "content-type": "application/json", ...options?.headers },
});
const body = await response.json();
if (!response.ok || body.ok === false) throw new Error(body.error ?? `Request failed with ${response.status}`);
return body;
}
async function registerCapabilities() {
await api(`/api/clients/${encodeURIComponent(clientId)}/capabilities`, {
method: "PUT",
body: JSON.stringify({ actions: [notifyAction] }),
});
setConnection("connected", "Capability online");
}
async function answerAction(event) {
if (event.action.id !== notifyAction.id || event.action.version !== notifyAction.version) {
await postActionResult(event.requestId, { clientId, ok: false, error: "Unsupported action" });
return;
}
const message = event.input?.message;
if (typeof message !== "string" || !message) {
await postActionResult(event.requestId, { clientId, ok: false, error: "Invalid notify input" });
return;
}
showNotification(message);
await postActionResult(event.requestId, { clientId, ok: true, result: { displayed: true } });
}
async function postActionResult(requestId, result) {
await api(`/api/action-results/${encodeURIComponent(requestId)}`, {
method: "POST",
body: JSON.stringify(result),
});
}
function handleEvent(event) {
switch (event.type) {
case "ready":
void registerCapabilities().catch((error) => setConnection("error", error.message));
break;
case "user_prompt":
appendMessage(event.clientId === clientId ? "You" : "Peer", event.text, "user");
break;
case "assistant_start":
assistantContent = undefined;
break;
case "assistant_delta":
if (!assistantContent) assistantContent = appendMessage("Pi", "", "assistant");
assistantContent.textContent += event.text;
transcript.scrollTop = transcript.scrollHeight;
break;
case "assistant_end":
assistantContent = undefined;
break;
case "tool_start":
appendMessage("Tool", event.name, "tool");
break;
case "error":
appendMessage("Error", event.message, "error");
break;
case "action_request":
void answerAction(event).catch((error) => appendMessage("Error", error.message, "error"));
break;
}
}
const events = new EventSource(`/api/events?clientId=${encodeURIComponent(clientId)}`);
events.onmessage = ({ data }) => handleEvent(JSON.parse(data));
events.onerror = () => setConnection("error", "Reconnecting");
form.addEventListener("submit", async (event) => {
event.preventDefault();
const text = prompt.value.trim();
if (!text || busy) return;
busy = true;
setConnection("connected", "Pi is working");
prompt.value = "";
try {
await api("/api/prompt", {
method: "POST",
body: JSON.stringify({ clientId, text }),
});
} catch (error) {
appendMessage("Error", error instanceof Error ? error.message : String(error), "error");
} finally {
busy = false;
setConnection("connected", "Capability online");
prompt.focus();
}
});
prompt.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
form.requestSubmit();
}
});
testNotify.addEventListener("click", async () => {
if (busy) return;
busy = true;
setConnection("connected", "Invoking directly");
try {
await api("/api/commands/notify", {
method: "POST",
body: JSON.stringify({ clientId, message: "Browser capability confirmed." }),
});
} catch (error) {
appendMessage("Error", error instanceof Error ? error.message : String(error), "error");
} finally {
busy = false;
setConnection("connected", "Capability online");
}
});
window.addEventListener("beforeunload", () => events.close());

View file

@ -0,0 +1,51 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light" />
<link rel="icon" href="data:," />
<title>Pi capability spike</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<div class="page-grid" aria-hidden="true"></div>
<header class="masthead">
<div>
<p class="eyebrow">Client 02 / browser</p>
<h1>Pi, loosely coupled.</h1>
</div>
<div class="connection" data-state="connecting">
<span class="connection-dot"></span>
<span id="connection-label">Connecting</span>
</div>
</header>
<main>
<section class="transcript" id="transcript" aria-live="polite" aria-label="Conversation">
<div class="empty-state" id="empty-state">
<p class="index">01</p>
<p>Ask Pi to notify you, or fire the capability directly.</p>
</div>
</section>
<form class="composer" id="prompt-form">
<label for="prompt">Prompt</label>
<div class="input-row">
<textarea id="prompt" name="prompt" rows="2" placeholder="Notify me that the browser capability works" required></textarea>
<button type="submit" id="send" disabled>Send</button>
</div>
<div class="composer-meta">
<span>Enter to send · Shift+Enter for newline</span>
<button type="button" class="text-button" id="test-notify" disabled>Test notify directly</button>
</div>
</form>
</main>
<div class="notification-stage" aria-live="assertive" aria-atomic="true">
<div class="notification" id="notification" hidden></div>
</div>
<script type="module" src="/app.js"></script>
</body>
</html>

View file

@ -0,0 +1,376 @@
@layer reset, base, components, motion;
@layer reset {
*,
*::before,
*::after {
box-sizing: border-box;
}
body,
h1,
p {
margin: 0;
}
button,
textarea {
font: inherit;
}
}
@layer base {
:root {
--paper: oklch(0.96 0.025 86);
--ink: oklch(0.19 0.025 54);
--muted: oklch(0.49 0.04 70);
--rule: oklch(0.78 0.055 76);
--signal: oklch(0.78 0.18 77);
--hot: oklch(0.59 0.23 34);
--success: oklch(0.55 0.13 151);
font-family: "Avenir Next", "Century Gothic", sans-serif;
color: var(--ink);
background: var(--paper);
}
body {
min-block-size: 100svh;
display: grid;
grid-template-rows: auto 1fr;
padding: clamp(1rem, 3vw, 2.75rem);
overflow-x: hidden;
}
button,
textarea {
color: inherit;
}
button:focus-visible,
textarea:focus-visible {
outline: 3px solid var(--hot);
outline-offset: 3px;
}
.page-grid {
position: fixed;
inset: 0;
z-index: -1;
background-image:
linear-gradient(to right, color-mix(in oklch, var(--rule) 34%, transparent) 1px, transparent 1px),
linear-gradient(to bottom, color-mix(in oklch, var(--rule) 25%, transparent) 1px, transparent 1px);
background-size: 2rem 2rem;
mask-image: linear-gradient(to bottom, black, transparent 78%);
}
}
@layer components {
.masthead {
display: flex;
align-items: start;
justify-content: space-between;
gap: 2rem;
padding-block-end: clamp(2rem, 7vh, 5rem);
& h1 {
max-inline-size: 15ch;
font-family: "Iowan Old Style", "Palatino Linotype", Georgia, serif;
font-size: clamp(2.75rem, 8vw, 7.5rem);
font-weight: 600;
font-style: italic;
letter-spacing: -0.065em;
line-height: 0.84;
text-wrap: balance;
}
}
.eyebrow,
.connection,
.composer label,
.composer-meta {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.eyebrow {
margin-block-end: 1rem;
color: var(--hot);
}
.connection {
display: flex;
align-items: center;
gap: 0.55rem;
padding-block: 0.4rem;
white-space: nowrap;
& .connection-dot {
inline-size: 0.65rem;
block-size: 0.65rem;
border: 1px solid currentColor;
border-radius: 50%;
background: var(--signal);
}
&[data-state="connected"] .connection-dot {
background: var(--success);
}
&[data-state="error"] .connection-dot {
background: var(--hot);
}
}
main {
inline-size: min(100%, 68rem);
margin-inline-start: auto;
display: grid;
grid-template-rows: minmax(14rem, 1fr) auto;
gap: clamp(2rem, 5vh, 4rem);
}
.transcript {
align-self: end;
max-block-size: 42vh;
overflow-y: auto;
scrollbar-gutter: stable;
display: grid;
gap: 1.2rem;
padding-inline: 0.25rem 1rem;
}
.empty-state {
display: grid;
grid-template-columns: 4rem minmax(0, 26rem);
gap: 1rem;
align-items: start;
color: var(--muted);
font-family: "Iowan Old Style", "Palatino Linotype", Georgia, serif;
font-size: clamp(1.15rem, 2.5vw, 1.65rem);
line-height: 1.25;
& .index {
font-family: "Avenir Next", "Century Gothic", sans-serif;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.12em;
}
}
.message {
display: grid;
grid-template-columns: 4rem minmax(0, 42rem);
gap: 1rem;
line-height: 1.55;
white-space: pre-wrap;
& .role {
padding-block-start: 0.25rem;
color: var(--muted);
font-size: 0.67rem;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
}
&.user .content {
font-family: "Iowan Old Style", "Palatino Linotype", Georgia, serif;
font-size: 1.2rem;
}
&.tool .content {
color: var(--muted);
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
}
.composer {
border-block-start: 2px solid var(--ink);
padding-block-start: 0.75rem;
& label {
display: block;
margin-block-end: 0.6rem;
}
}
.input-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.75rem;
align-items: stretch;
}
textarea {
min-block-size: 5.5rem;
resize: vertical;
border: 0;
background: transparent;
padding: 0.65rem 0;
font-family: "Iowan Old Style", "Palatino Linotype", Georgia, serif;
font-size: clamp(1.15rem, 2.2vw, 1.55rem);
line-height: 1.35;
&::placeholder {
color: color-mix(in oklch, var(--muted) 65%, transparent);
}
}
button {
border: 0;
cursor: pointer;
}
#send {
min-inline-size: 6.5rem;
background: var(--ink);
color: var(--paper);
font-size: 0.8rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
transition: transform 160ms ease-out, background-color 160ms ease-out;
&:hover:not(:disabled) {
background: var(--hot);
transform: translateY(-2px);
}
&:disabled {
cursor: not-allowed;
opacity: 0.35;
}
}
.composer-meta {
display: flex;
justify-content: space-between;
gap: 1rem;
margin-block-start: 0.75rem;
color: var(--muted);
}
.text-button {
padding: 0;
background: transparent;
color: var(--hot);
font-weight: 800;
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 0.25rem;
&:disabled {
cursor: not-allowed;
opacity: 0.4;
}
}
.notification-stage {
position: fixed;
inset: 0;
z-index: 10;
pointer-events: none;
overflow: hidden;
}
.notification {
position: absolute;
inset-block-start: 50%;
inset-inline-start: 50%;
max-inline-size: min(80vw, 46rem);
padding: clamp(1rem, 3vw, 2rem) clamp(1.25rem, 5vw, 3.5rem);
background: var(--signal);
color: var(--ink);
box-shadow: 0.8rem 0.8rem 0 var(--hot);
font-family: "Iowan Old Style", "Palatino Linotype", Georgia, serif;
font-size: clamp(1.75rem, 6vw, 5rem);
font-weight: 700;
font-style: italic;
line-height: 0.95;
text-align: center;
text-wrap: balance;
}
@media (width < 44rem) {
body {
padding: 1rem;
}
.masthead {
align-items: end;
padding-block-end: 3rem;
}
.connection {
& #connection-label {
display: none;
}
}
.message,
.empty-state {
grid-template-columns: 2.5rem minmax(0, 1fr);
}
.input-row {
grid-template-columns: 1fr;
}
#send {
min-block-size: 3.25rem;
}
.composer-meta {
align-items: start;
flex-direction: column;
}
}
}
@layer motion {
.notification.is-flying {
animation: notify-spin-out 2.35s cubic-bezier(0.77, 0, 0.22, 1) both;
}
@keyframes notify-spin-out {
0% {
opacity: 0;
transform: translate(-50%, -50%) rotate(-18deg) scale(0.35);
}
13% {
opacity: 1;
transform: translate(-50%, -50%) rotate(2deg) scale(1.08);
}
25%,
52% {
opacity: 1;
transform: translate(-50%, -50%) rotate(0deg) scale(1);
}
100% {
opacity: 0;
transform: translate(calc(-50% + 112vw), calc(-50% - 82vh)) rotate(1600deg) scale(0.08);
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
}
.notification.is-flying {
animation: notify-fade 2.35s ease-out both;
}
@keyframes notify-fade {
0%, 100% { opacity: 0; transform: translate(-50%, -50%); }
15%, 75% { opacity: 1; transform: translate(-50%, -50%); }
}
}
}