diff --git a/packages/coding-agent/examples/capability-capnweb-spike/README.md b/packages/coding-agent/examples/capability-capnweb-spike/README.md new file mode 100644 index 000000000..55347355f --- /dev/null +++ b/packages/coding-agent/examples/capability-capnweb-spike/README.md @@ -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 diff --git a/packages/coding-agent/examples/capability-capnweb-spike/binding-registry.ts b/packages/coding-agent/examples/capability-capnweb-spike/binding-registry.ts new file mode 100644 index 000000000..904bee2dd --- /dev/null +++ b/packages/coding-agent/examples/capability-capnweb-spike/binding-registry.ts @@ -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; + readonly rpc: RpcStub; + connected: boolean; +} + +export class BindingRegistry { + readonly #clientsById = new Map(); + + register(registration: ClientRegistration, rpc: RpcStub): 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 { + this.#requireConnected(client); + const call = client.rpc.event(event); + try { + await call; + } finally { + call[Symbol.dispose](); + } + } + + async broadcast(event: ServerEvent): Promise { + 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 { + 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((_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`); + } + } +} diff --git a/packages/coding-agent/examples/capability-capnweb-spike/extension.ts b/packages/coding-agent/examples/capability-capnweb-spike/extension.ts new file mode 100644 index 000000000..a3dc1050b --- /dev/null +++ b/packages/coding-agent/examples/capability-capnweb-spike/extension.ts @@ -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; +} + +export interface NotifyExtensionContext { + clientBindings: ClientBindings; +} + +export type ServerCommand = (input: unknown, context: NotifyExtensionContext, signal?: AbortSignal) => Promise; + +export type MessageEndEvent = Extract; + +export type MessageEndHandler = ( + event: MessageEndEvent, + context: NotifyExtensionContext, + signal?: AbortSignal, +) => Promise | void; + +export interface ServerExtensionApi { + registerTool(tool: AgentHarnessTool): void; + registerCommand(name: string, command: ServerCommand): void; + on(type: "message_end", handler: MessageEndHandler): void; +} + +async function notifyClient( + input: unknown, + context: NotifyExtensionContext, + signal?: AbortSignal, +): Promise { + 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 }, + }; + }, + }); +} diff --git a/packages/coding-agent/examples/capability-capnweb-spike/package-lock.json b/packages/coding-agent/examples/capability-capnweb-spike/package-lock.json new file mode 100644 index 000000000..c76516a0e --- /dev/null +++ b/packages/coding-agent/examples/capability-capnweb-spike/package-lock.json @@ -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 + } + } + } + } +} diff --git a/packages/coding-agent/examples/capability-capnweb-spike/package.json b/packages/coding-agent/examples/capability-capnweb-spike/package.json new file mode 100644 index 000000000..e89a48186 --- /dev/null +++ b/packages/coding-agent/examples/capability-capnweb-spike/package.json @@ -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" + } +} diff --git a/packages/coding-agent/examples/capability-capnweb-spike/protocol.ts b/packages/coding-agent/examples/capability-capnweb-spike/protocol.ts new file mode 100644 index 000000000..a81f9c3b7 --- /dev/null +++ b/packages/coding-agent/examples/capability-capnweb-spike/protocol.ts @@ -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; +export type NotifyOutput = Static; + +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; + notify(input: NotifyInput): Promise; +} + +/** The main Cap'n Web interface exported by the server for one connection. */ +export interface ServerApi { + connect(registration: ClientRegistration): Promise; + prompt(text: string): Promise; + command(name: string, input: unknown): Promise; +} diff --git a/packages/coding-agent/examples/capability-capnweb-spike/server.ts b/packages/coding-agent/examples/capability-capnweb-spike/server.ts new file mode 100644 index 000000000..e35037eae --- /dev/null +++ b/packages/coding-agent/examples/capability-capnweb-spike/server.ts @@ -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[] = []; +const commands = new Map(); +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 { + 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; + #client: ConnectedClient | undefined; + + constructor(remoteClient: () => RpcStub) { + super(); + this.#remoteClient = remoteClient; + } + + async connect(registration: ClientRegistration): Promise { + 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 { + 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 { + 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 { + 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 | undefined; + const connection = new PiServerConnection(() => { + if (!remoteClient) throw new Error("Remote client binding is not ready"); + return remoteClient; + }); + const session = new RpcSession(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)); + }); +} diff --git a/packages/coding-agent/examples/capability-capnweb-spike/tui.ts b/packages/coding-agent/examples/capability-capnweb-spike/tui.ts new file mode 100644 index 000000000..fe175d8f3 --- /dev/null +++ b/packages/coding-agent/examples/capability-capnweb-spike/tui.ts @@ -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 { + handleEvent(event); + } + + async notify(input: NotifyInput): Promise { + 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): Promise { + try { + await call; + } finally { + call[Symbol.dispose](); + } +} + +const server = newWebSocketRpcSession(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 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"); +}); diff --git a/packages/coding-agent/examples/capability-capnweb-spike/web/app.js b/packages/coding-agent/examples/capability-capnweb-spike/web/app.js new file mode 100644 index 000000000..5a7a6a227 --- /dev/null +++ b/packages/coding-agent/examples/capability-capnweb-spike/web/app.js @@ -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]()); diff --git a/packages/coding-agent/examples/capability-capnweb-spike/web/index.html b/packages/coding-agent/examples/capability-capnweb-spike/web/index.html new file mode 100644 index 000000000..e04bb29d3 --- /dev/null +++ b/packages/coding-agent/examples/capability-capnweb-spike/web/index.html @@ -0,0 +1,51 @@ + + + + + + + + Pi Cap'n Web capability spike + + + + +
+
+

Client binding / Cap'n Web

+

Pi, called remotely.

+
+
+ + Connecting +
+
+ +
+
+
+

01

+

Ask Pi to notify you, or call the client binding directly.

+
+
+ +
+ +
+ + +
+
+ Enter to send · Shift+Enter for newline + +
+
+
+ +
+ +
+ + + + diff --git a/packages/coding-agent/examples/capability-capnweb-spike/web/styles.css b/packages/coding-agent/examples/capability-capnweb-spike/web/styles.css new file mode 100644 index 000000000..4a821f15a --- /dev/null +++ b/packages/coding-agent/examples/capability-capnweb-spike/web/styles.css @@ -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%); } + } + } +} diff --git a/packages/coding-agent/examples/capability-capnweb-spike/ws-transport.ts b/packages/coding-agent/examples/capability-capnweb-spike/ws-transport.ts new file mode 100644 index 000000000..559e8b3b2 --- /dev/null +++ b/packages/coding-agent/examples/capability-capnweb-spike/ws-transport.ts @@ -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 { + if (this.#closedError || this.#socket.readyState !== 1) { + return Promise.reject(this.#closedError ?? new Error("WebSocket is not open")); + } + return new Promise((resolve, reject) => { + this.#socket.send(message, (error) => { + if (error) reject(error); + else resolve(); + }); + }); + } + + receive(): Promise { + const message = this.#messages.shift(); + if (message !== undefined) return Promise.resolve(message); + if (this.#closedError) return Promise.reject(this.#closedError); + return new Promise((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); + } +} diff --git a/packages/coding-agent/examples/capability-spike/README.md b/packages/coding-agent/examples/capability-spike/README.md new file mode 100644 index 000000000..a8fd5d678 --- /dev/null +++ b/packages/coding-agent/examples/capability-spike/README.md @@ -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 diff --git a/packages/coding-agent/examples/capability-spike/capability-registry.ts b/packages/coding-agent/examples/capability-spike/capability-registry.ts new file mode 100644 index 000000000..c9aabc7f9 --- /dev/null +++ b/packages/coding-agent/examples/capability-spike/capability-registry.ts @@ -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; + send(event: ServerEvent): void; +} + +interface PendingInvocation { + clientId: string; + action: ClientAction; + 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(); + private readonly pending = new Map(); + + 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( + clientId: string, + action: ClientAction, + input: unknown, + options: { signal?: AbortSignal; timeoutMs?: number } = {}, + ): Promise> { + 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((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)); + } + } + } +} diff --git a/packages/coding-agent/examples/capability-spike/extension.ts b/packages/coding-agent/examples/capability-spike/extension.ts new file mode 100644 index 000000000..078253acd --- /dev/null +++ b/packages/coding-agent/examples/capability-spike/extension.ts @@ -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( + action: { + id: string; + version: number; + input: TInputSchema; + output: TOutputSchema; + }, + input: unknown, + options?: { signal?: AbortSignal; timeoutMs?: number }, + ): Promise>; +} + +export interface NotifyExtensionContext { + clientActions: ClientActions; +} + +export type ServerCommand = (input: unknown, context: NotifyExtensionContext, signal?: AbortSignal) => Promise; + +export type MessageEndEvent = Extract; + +export type MessageEndHandler = ( + event: MessageEndEvent, + context: NotifyExtensionContext, + signal?: AbortSignal, +) => Promise | void; + +export interface ServerExtensionApi { + registerTool(tool: AgentHarnessTool): void; + registerCommand(name: string, command: ServerCommand): void; + on(type: "message_end", handler: MessageEndHandler): void; +} + +async function notifyClient( + input: unknown, + context: NotifyExtensionContext, + signal?: AbortSignal, +): Promise { + 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 }, + }; + }, + }); +} diff --git a/packages/coding-agent/examples/capability-spike/protocol.ts b/packages/coding-agent/examples/capability-spike/protocol.ts new file mode 100644 index 000000000..1bfd16667 --- /dev/null +++ b/packages/coding-agent/examples/capability-spike/protocol.ts @@ -0,0 +1,55 @@ +import { type Static, type TSchema, Type } from "typebox"; + +export interface ClientAction { + 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; + +export type NotifyInput = Static; +export type NotifyOutput = Static; + +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; +} diff --git a/packages/coding-agent/examples/capability-spike/server.ts b/packages/coding-agent/examples/capability-spike/server.ts new file mode 100644 index 000000000..1e1e616ed --- /dev/null +++ b/packages/coding-agent/examples/capability-spike/server.ts @@ -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[] = []; +const commands = new Map(); +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 { + 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 | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : 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)); + }); +} diff --git a/packages/coding-agent/examples/capability-spike/tui.ts b/packages/coding-agent/examples/capability-spike/tui.ts new file mode 100644 index 000000000..ef133fa51 --- /dev/null +++ b/packages/coding-agent/examples/capability-spike/tui.ts @@ -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 { + 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): Promise { + 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 { + 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 { + 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 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"); +}); diff --git a/packages/coding-agent/examples/capability-spike/web/app.js b/packages/coding-agent/examples/capability-spike/web/app.js new file mode 100644 index 000000000..079205611 --- /dev/null +++ b/packages/coding-agent/examples/capability-spike/web/app.js @@ -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()); diff --git a/packages/coding-agent/examples/capability-spike/web/index.html b/packages/coding-agent/examples/capability-spike/web/index.html new file mode 100644 index 000000000..97ad67766 --- /dev/null +++ b/packages/coding-agent/examples/capability-spike/web/index.html @@ -0,0 +1,51 @@ + + + + + + + + Pi capability spike + + + + +
+
+

Client 02 / browser

+

Pi, loosely coupled.

+
+
+ + Connecting +
+
+ +
+
+
+

01

+

Ask Pi to notify you, or fire the capability directly.

+
+
+ +
+ +
+ + +
+
+ Enter to send · Shift+Enter for newline + +
+
+
+ +
+ +
+ + + + diff --git a/packages/coding-agent/examples/capability-spike/web/styles.css b/packages/coding-agent/examples/capability-spike/web/styles.css new file mode 100644 index 000000000..4a821f15a --- /dev/null +++ b/packages/coding-agent/examples/capability-spike/web/styles.css @@ -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%); } + } + } +}