mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(coding-agent): add client capability binding experiments
This commit is contained in:
parent
8eef62ed3e
commit
fec371a33f
21 changed files with 3612 additions and 0 deletions
|
|
@ -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
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
import type { RpcStub } from "capnweb";
|
||||
import { Value } from "typebox/value";
|
||||
import {
|
||||
type ClientBindingApi,
|
||||
type ClientRegistration,
|
||||
type NotifyInput,
|
||||
type NotifyOutput,
|
||||
notifyBinding,
|
||||
type ServerEvent,
|
||||
} from "./protocol.ts";
|
||||
|
||||
export class BindingError extends Error {
|
||||
readonly code:
|
||||
| "client_unavailable"
|
||||
| "binding_unavailable"
|
||||
| "invalid_input"
|
||||
| "invalid_output"
|
||||
| "aborted"
|
||||
| "timeout";
|
||||
|
||||
constructor(
|
||||
code: "client_unavailable" | "binding_unavailable" | "invalid_input" | "invalid_output" | "aborted" | "timeout",
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "BindingError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ConnectedClient {
|
||||
readonly id: string;
|
||||
readonly bindings: ReadonlyMap<string, number>;
|
||||
readonly rpc: RpcStub<ClientBindingApi>;
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
export class BindingRegistry {
|
||||
readonly #clientsById = new Map<string, ConnectedClient>();
|
||||
|
||||
register(registration: ClientRegistration, rpc: RpcStub<ClientBindingApi>): ConnectedClient {
|
||||
if (this.#clientsById.has(registration.clientId)) {
|
||||
throw new BindingError("client_unavailable", `Client ${registration.clientId} is already connected`);
|
||||
}
|
||||
const client: ConnectedClient = {
|
||||
id: registration.clientId,
|
||||
bindings: new Map(registration.bindings.map((binding) => [binding.id, binding.version])),
|
||||
rpc,
|
||||
connected: true,
|
||||
};
|
||||
this.#clientsById.set(client.id, client);
|
||||
return client;
|
||||
}
|
||||
|
||||
unregister(client: ConnectedClient): void {
|
||||
if (this.#clientsById.get(client.id) !== client) return;
|
||||
client.connected = false;
|
||||
this.#clientsById.delete(client.id);
|
||||
}
|
||||
|
||||
async send(client: ConnectedClient, event: ServerEvent): Promise<void> {
|
||||
this.#requireConnected(client);
|
||||
const call = client.rpc.event(event);
|
||||
try {
|
||||
await call;
|
||||
} finally {
|
||||
call[Symbol.dispose]();
|
||||
}
|
||||
}
|
||||
|
||||
async broadcast(event: ServerEvent): Promise<void> {
|
||||
await Promise.all(
|
||||
[...this.#clientsById.values()].map(async (client) => {
|
||||
try {
|
||||
await this.send(client, event);
|
||||
} catch {
|
||||
this.unregister(client);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async notify(
|
||||
client: ConnectedClient,
|
||||
input: unknown,
|
||||
options: { signal?: AbortSignal; timeoutMs?: number } = {},
|
||||
): Promise<NotifyOutput> {
|
||||
this.#requireConnected(client);
|
||||
if (client.bindings.get(notifyBinding.id) !== notifyBinding.version) {
|
||||
throw new BindingError(
|
||||
"binding_unavailable",
|
||||
`Client ${client.id} does not provide ${notifyBinding.id}@${notifyBinding.version}`,
|
||||
);
|
||||
}
|
||||
if (!Value.Check(notifyBinding.input, input)) {
|
||||
throw new BindingError("invalid_input", `Input does not match ${notifyBinding.id}@${notifyBinding.version}`);
|
||||
}
|
||||
if (options.signal?.aborted) {
|
||||
throw new BindingError("aborted", `${notifyBinding.id} was aborted`);
|
||||
}
|
||||
|
||||
const rpcCall = client.rpc.notify(input as NotifyInput);
|
||||
const timeoutMs = options.timeoutMs ?? 10_000;
|
||||
let rejectCancellation: ((error: Error) => void) | undefined;
|
||||
const cancellation = new Promise<never>((_resolve, reject) => {
|
||||
rejectCancellation = reject;
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
rejectCancellation?.(new BindingError("timeout", `${notifyBinding.id} timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
const onAbort = () => {
|
||||
rejectCancellation?.(new BindingError("aborted", `${notifyBinding.id} was aborted`));
|
||||
};
|
||||
options.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
try {
|
||||
const output = await Promise.race([rpcCall, cancellation]);
|
||||
if (!Value.Check(notifyBinding.output, output)) {
|
||||
throw new BindingError(
|
||||
"invalid_output",
|
||||
`Output does not match ${notifyBinding.id}@${notifyBinding.version}`,
|
||||
);
|
||||
}
|
||||
return { displayed: true };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
options.signal?.removeEventListener("abort", onAbort);
|
||||
rpcCall[Symbol.dispose]();
|
||||
}
|
||||
}
|
||||
|
||||
#requireConnected(client: ConnectedClient): void {
|
||||
if (!client.connected || this.#clientsById.get(client.id) !== client) {
|
||||
throw new BindingError("client_unavailable", `Client ${client.id} is not connected`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import type { AgentHarnessEvent, AgentHarnessTool } from "@earendil-works/pi-agent-core";
|
||||
import { contentText } from "@earendil-works/pi-ai";
|
||||
import { Type } from "typebox";
|
||||
import type { NotifyInput, NotifyOutput } from "./protocol.ts";
|
||||
|
||||
export interface ClientBindings {
|
||||
notify(input: NotifyInput, options?: { signal?: AbortSignal; timeoutMs?: number }): Promise<NotifyOutput>;
|
||||
}
|
||||
|
||||
export interface NotifyExtensionContext {
|
||||
clientBindings: ClientBindings;
|
||||
}
|
||||
|
||||
export type ServerCommand = (input: unknown, context: NotifyExtensionContext, signal?: AbortSignal) => Promise<unknown>;
|
||||
|
||||
export type MessageEndEvent = Extract<AgentHarnessEvent, { type: "message_end" }>;
|
||||
|
||||
export type MessageEndHandler = (
|
||||
event: MessageEndEvent,
|
||||
context: NotifyExtensionContext,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<void> | void;
|
||||
|
||||
export interface ServerExtensionApi {
|
||||
registerTool(tool: AgentHarnessTool<NotifyExtensionContext>): void;
|
||||
registerCommand(name: string, command: ServerCommand): void;
|
||||
on(type: "message_end", handler: MessageEndHandler): void;
|
||||
}
|
||||
|
||||
async function notifyClient(
|
||||
input: unknown,
|
||||
context: NotifyExtensionContext,
|
||||
signal?: AbortSignal,
|
||||
): Promise<NotifyOutput> {
|
||||
if (
|
||||
typeof input !== "object" ||
|
||||
input === null ||
|
||||
!("message" in input) ||
|
||||
typeof input.message !== "string" ||
|
||||
input.message.trim().length === 0
|
||||
) {
|
||||
throw new Error("notify requires a non-empty message");
|
||||
}
|
||||
return context.clientBindings.notify({ message: input.message.trim() }, { signal });
|
||||
}
|
||||
|
||||
const notifyParameters = Type.Object(
|
||||
{
|
||||
message: Type.String({ description: "Notification text", minLength: 1, maxLength: 500 }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export function notifyExtension(pi: ServerExtensionApi): void {
|
||||
pi.registerCommand("notify", notifyClient);
|
||||
pi.on("message_end", async (event, context, signal) => {
|
||||
if (event.message.role !== "assistant") return;
|
||||
if (contentText(event.message.content, "").trim() === "Paris") {
|
||||
await notifyClient({ message: "❤️" }, context, signal);
|
||||
}
|
||||
});
|
||||
pi.registerTool({
|
||||
name: "notify",
|
||||
label: "Notify client",
|
||||
description: "Show a brief notification on the client that submitted the current prompt.",
|
||||
parameters: notifyParameters,
|
||||
async execute(_toolCallId, params, signal, _onUpdate, context) {
|
||||
const input = typeof params === "object" && params !== null ? params : {};
|
||||
const message = "message" in input && typeof input.message === "string" ? input.message : "";
|
||||
await notifyClient({ message }, context, signal);
|
||||
return {
|
||||
content: [{ type: "text", text: `Notified the originating client: ${message}` }],
|
||||
details: { displayed: true },
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
595
packages/coding-agent/examples/capability-capnweb-spike/package-lock.json
generated
Normal file
595
packages/coding-agent/examples/capability-capnweb-spike/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,595 @@
|
|||
{
|
||||
"name": "pi-capability-capnweb-spike",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pi-capability-capnweb-spike",
|
||||
"dependencies": {
|
||||
"capnweb": "0.10.0",
|
||||
"ws": "8.21.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "24.12.4",
|
||||
"@types/ws": "8.18.1",
|
||||
"tsx": "4.22.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.12.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz",
|
||||
"integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/capnweb": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/capnweb/-/capnweb-0.10.0.tgz",
|
||||
"integrity": "sha512-lQ2nlzaifkqBeNfs8RLErn7NZ8FNf3vn1W7J3nSH73edWG+0B5nHZGR51S2S9HMZEuZcEypOO469dZRY/BluFA==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
".",
|
||||
"packages/*"
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.1",
|
||||
"@esbuild/android-arm": "0.28.1",
|
||||
"@esbuild/android-arm64": "0.28.1",
|
||||
"@esbuild/android-x64": "0.28.1",
|
||||
"@esbuild/darwin-arm64": "0.28.1",
|
||||
"@esbuild/darwin-x64": "0.28.1",
|
||||
"@esbuild/freebsd-arm64": "0.28.1",
|
||||
"@esbuild/freebsd-x64": "0.28.1",
|
||||
"@esbuild/linux-arm": "0.28.1",
|
||||
"@esbuild/linux-arm64": "0.28.1",
|
||||
"@esbuild/linux-ia32": "0.28.1",
|
||||
"@esbuild/linux-loong64": "0.28.1",
|
||||
"@esbuild/linux-mips64el": "0.28.1",
|
||||
"@esbuild/linux-ppc64": "0.28.1",
|
||||
"@esbuild/linux-riscv64": "0.28.1",
|
||||
"@esbuild/linux-s390x": "0.28.1",
|
||||
"@esbuild/linux-x64": "0.28.1",
|
||||
"@esbuild/netbsd-arm64": "0.28.1",
|
||||
"@esbuild/netbsd-x64": "0.28.1",
|
||||
"@esbuild/openbsd-arm64": "0.28.1",
|
||||
"@esbuild/openbsd-x64": "0.28.1",
|
||||
"@esbuild/openharmony-arm64": "0.28.1",
|
||||
"@esbuild/sunos-x64": "0.28.1",
|
||||
"@esbuild/win32-arm64": "0.28.1",
|
||||
"@esbuild/win32-ia32": "0.28.1",
|
||||
"@esbuild/win32-x64": "0.28.1"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.22.1",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.1.tgz",
|
||||
"integrity": "sha512-TvncJykhxAzFCk0VQZKBTClall4Pm7qXDSodb6uxi8QFa8X8mT6ABjxxsQ2opDRYxG7AzcRWXaFtruz5HJKuWg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.28.0"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
|
||||
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import { type Static, Type } from "typebox";
|
||||
|
||||
export const protocolVersion = 1;
|
||||
|
||||
export const notifyBinding = {
|
||||
id: "pi.notify",
|
||||
version: 1,
|
||||
input: Type.Object(
|
||||
{
|
||||
message: Type.String({ minLength: 1, maxLength: 500 }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
output: Type.Object(
|
||||
{
|
||||
displayed: Type.Literal(true),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
} as const;
|
||||
|
||||
export type NotifyInput = Static<typeof notifyBinding.input>;
|
||||
export type NotifyOutput = Static<typeof notifyBinding.output>;
|
||||
|
||||
export interface BindingRegistration {
|
||||
id: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface ClientRegistration {
|
||||
clientId: string;
|
||||
protocolVersion: number;
|
||||
bindings: BindingRegistration[];
|
||||
}
|
||||
|
||||
export const clientRegistrationSchema = Type.Object(
|
||||
{
|
||||
clientId: Type.String({ minLength: 1, maxLength: 80, pattern: "^[a-zA-Z0-9_-]+$" }),
|
||||
protocolVersion: Type.Literal(protocolVersion),
|
||||
bindings: Type.Array(
|
||||
Type.Object(
|
||||
{
|
||||
id: Type.String({ minLength: 1, maxLength: 100 }),
|
||||
version: Type.Integer({ minimum: 1 }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export type ServerEvent =
|
||||
| { type: "ready"; clientId: string; bindings: BindingRegistration[] }
|
||||
| { type: "user_prompt"; clientId: string; text: string }
|
||||
| { type: "run_start" }
|
||||
| { type: "run_end" }
|
||||
| { type: "assistant_start" }
|
||||
| { type: "assistant_delta"; text: string }
|
||||
| { type: "assistant_end" }
|
||||
| { type: "tool_start"; name: string }
|
||||
| { type: "tool_end"; name: string; isError: boolean }
|
||||
| { type: "error"; message: string };
|
||||
|
||||
/** The main Cap'n Web interface exported by each connected client. */
|
||||
export interface ClientBindingApi {
|
||||
event(event: ServerEvent): Promise<void>;
|
||||
notify(input: NotifyInput): Promise<NotifyOutput>;
|
||||
}
|
||||
|
||||
/** The main Cap'n Web interface exported by the server for one connection. */
|
||||
export interface ServerApi {
|
||||
connect(registration: ClientRegistration): Promise<void>;
|
||||
prompt(text: string): Promise<void>;
|
||||
command(name: string, input: unknown): Promise<void>;
|
||||
}
|
||||
|
|
@ -0,0 +1,292 @@
|
|||
import { readFile } from "node:fs/promises";
|
||||
import { createServer, type ServerResponse } from "node:http";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
AgentHarness,
|
||||
type AgentHarnessEvent,
|
||||
type AgentHarnessTool,
|
||||
InMemorySessionStorage,
|
||||
Session,
|
||||
} from "@earendil-works/pi-agent-core";
|
||||
import { createModels, type Model } from "@earendil-works/pi-ai";
|
||||
import { anthropicProvider } from "@earendil-works/pi-ai/providers/anthropic";
|
||||
import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";
|
||||
import { RpcSession, type RpcStub, RpcTarget } from "capnweb";
|
||||
import { Value } from "typebox/value";
|
||||
import { WebSocketServer } from "ws";
|
||||
import { BindingRegistry, type ConnectedClient } from "./binding-registry.ts";
|
||||
import {
|
||||
type MessageEndHandler,
|
||||
type NotifyExtensionContext,
|
||||
notifyExtension,
|
||||
type ServerCommand,
|
||||
type ServerExtensionApi,
|
||||
} from "./extension.ts";
|
||||
import {
|
||||
type ClientBindingApi,
|
||||
type ClientRegistration,
|
||||
clientRegistrationSchema,
|
||||
type ServerApi,
|
||||
type ServerEvent,
|
||||
} from "./protocol.ts";
|
||||
import { NodeWebSocketTransport } from "./ws-transport.ts";
|
||||
|
||||
const host = process.env.PI_CAPNWEB_SPIKE_HOST ?? "127.0.0.1";
|
||||
const port = Number(process.env.PI_CAPNWEB_SPIKE_PORT ?? "8788");
|
||||
const modelSpec = process.env.PI_CAPNWEB_SPIKE_MODEL ?? "openai/gpt-5.4-nano";
|
||||
const [providerId, modelId] = modelSpec.split("/", 2);
|
||||
if (!providerId || !modelId) {
|
||||
throw new Error(`PI_CAPNWEB_SPIKE_MODEL must use provider/model syntax, received ${modelSpec}`);
|
||||
}
|
||||
|
||||
const models = createModels();
|
||||
models.setProvider(openaiProvider());
|
||||
models.setProvider(anthropicProvider());
|
||||
|
||||
function createFallbackModel(provider: string, id: string): Model<"openai-responses" | "anthropic-messages"> {
|
||||
const common = {
|
||||
id,
|
||||
name: id,
|
||||
reasoning: false,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 16_384,
|
||||
};
|
||||
if (provider === "openai") {
|
||||
return {
|
||||
...common,
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
input: ["text"],
|
||||
};
|
||||
}
|
||||
if (provider === "anthropic") {
|
||||
return {
|
||||
...common,
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
input: ["text"],
|
||||
};
|
||||
}
|
||||
throw new Error(`Unsupported provider ${provider}. This spike registers openai and anthropic.`);
|
||||
}
|
||||
|
||||
const model = models.getModel(providerId, modelId) ?? createFallbackModel(providerId, modelId);
|
||||
const registry = new BindingRegistry();
|
||||
const tools: AgentHarnessTool<NotifyExtensionContext>[] = [];
|
||||
const commands = new Map<string, ServerCommand>();
|
||||
const messageEndHandlers: MessageEndHandler[] = [];
|
||||
const extensionApi: ServerExtensionApi = {
|
||||
registerTool(tool) {
|
||||
tools.push(tool);
|
||||
},
|
||||
registerCommand(name, command) {
|
||||
commands.set(name, command);
|
||||
},
|
||||
on(type, handler) {
|
||||
if (type === "message_end") messageEndHandlers.push(handler);
|
||||
},
|
||||
};
|
||||
notifyExtension(extensionApi);
|
||||
|
||||
let activeOriginClient: ConnectedClient | undefined;
|
||||
let promptInFlight = false;
|
||||
|
||||
function contextFor(client: ConnectedClient): NotifyExtensionContext {
|
||||
return {
|
||||
clientBindings: {
|
||||
notify: (input, options) => registry.notify(client, input, options),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const harness = new AgentHarness({
|
||||
session: new Session(new InMemorySessionStorage()),
|
||||
models,
|
||||
model,
|
||||
thinkingLevel: "off",
|
||||
tools,
|
||||
toolContext: () => {
|
||||
if (!activeOriginClient) throw new Error("No originating client is bound to this turn");
|
||||
return contextFor(activeOriginClient);
|
||||
},
|
||||
systemPrompt: [
|
||||
"You are a concise assistant.",
|
||||
"When asked for the capital of France, respond with exactly Paris and nothing else.",
|
||||
"When the user asks for a notification, call the notify tool with the exact message they want shown.",
|
||||
].join(" "),
|
||||
});
|
||||
|
||||
harness.subscribe(async (event, signal) => {
|
||||
for (const wireEvent of toServerEvents(event)) await registry.broadcast(wireEvent);
|
||||
if (event.type === "message_end" && messageEndHandlers.length > 0) {
|
||||
if (!activeOriginClient) throw new Error("No originating client is bound to this assistant response");
|
||||
const context = contextFor(activeOriginClient);
|
||||
for (const handler of messageEndHandlers) await handler(event, context, signal);
|
||||
}
|
||||
});
|
||||
|
||||
function toServerEvents(event: AgentHarnessEvent): ServerEvent[] {
|
||||
switch (event.type) {
|
||||
case "agent_start":
|
||||
return [{ type: "run_start" }];
|
||||
case "agent_end":
|
||||
return [{ type: "run_end" }];
|
||||
case "message_start":
|
||||
return event.message.role === "assistant" ? [{ type: "assistant_start" }] : [];
|
||||
case "message_update":
|
||||
return event.assistantMessageEvent.type === "text_delta"
|
||||
? [{ type: "assistant_delta", text: event.assistantMessageEvent.delta }]
|
||||
: [];
|
||||
case "message_end":
|
||||
if (event.message.role !== "assistant") return [];
|
||||
return [
|
||||
{ type: "assistant_end" },
|
||||
...(event.message.errorMessage ? [{ type: "error" as const, message: event.message.errorMessage }] : []),
|
||||
];
|
||||
case "tool_execution_start":
|
||||
return [{ type: "tool_start", name: event.toolName }];
|
||||
case "tool_execution_end":
|
||||
return [{ type: "tool_end", name: event.toolName, isError: event.isError }];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function runPrompt(client: ConnectedClient, text: string): Promise<void> {
|
||||
if (promptInFlight) throw new Error("The shared session is already processing a prompt");
|
||||
promptInFlight = true;
|
||||
activeOriginClient = client;
|
||||
await registry.broadcast({ type: "user_prompt", clientId: client.id, text });
|
||||
try {
|
||||
const assistant = await harness.prompt(text);
|
||||
if (assistant.stopReason === "error") throw new Error(assistant.errorMessage ?? "Agent run failed");
|
||||
} finally {
|
||||
activeOriginClient = undefined;
|
||||
promptInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
class PiServerConnection extends RpcTarget implements ServerApi {
|
||||
readonly #remoteClient: () => RpcStub<ClientBindingApi>;
|
||||
#client: ConnectedClient | undefined;
|
||||
|
||||
constructor(remoteClient: () => RpcStub<ClientBindingApi>) {
|
||||
super();
|
||||
this.#remoteClient = remoteClient;
|
||||
}
|
||||
|
||||
async connect(registration: ClientRegistration): Promise<void> {
|
||||
if (this.#client) throw new Error("This RPC session is already registered");
|
||||
if (!Value.Check(clientRegistrationSchema, registration)) throw new Error("Invalid client registration");
|
||||
const client = registry.register(registration, this.#remoteClient());
|
||||
this.#client = client;
|
||||
try {
|
||||
await registry.send(client, { type: "ready", clientId: client.id, bindings: registration.bindings });
|
||||
} catch (error) {
|
||||
registry.unregister(client);
|
||||
this.#client = undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async prompt(text: string): Promise<void> {
|
||||
const client = this.#requireClient();
|
||||
if (typeof text !== "string" || text.trim().length === 0) throw new Error("Prompt is required");
|
||||
await runPrompt(client, text.trim());
|
||||
}
|
||||
|
||||
async command(name: string, input: unknown): Promise<void> {
|
||||
const client = this.#requireClient();
|
||||
if (typeof name !== "string") throw new Error("Command name must be a string");
|
||||
const command = commands.get(name);
|
||||
if (!command) throw new Error(`Unknown command ${name}`);
|
||||
await command(input, contextFor(client));
|
||||
}
|
||||
|
||||
[Symbol.dispose](): void {
|
||||
if (!this.#client) return;
|
||||
registry.unregister(this.#client);
|
||||
this.#client = undefined;
|
||||
}
|
||||
|
||||
#requireClient(): ConnectedClient {
|
||||
if (!this.#client?.connected) throw new Error("RPC client has not connected");
|
||||
return this.#client;
|
||||
}
|
||||
}
|
||||
|
||||
function sendStatic(response: ServerResponse, path: string, type: string): Promise<void> {
|
||||
return readFile(path).then((body) => {
|
||||
response.writeHead(200, { "content-type": type, "cache-control": "no-store" });
|
||||
response.end(body);
|
||||
});
|
||||
}
|
||||
|
||||
function sendJson(response: ServerResponse, status: number, body: unknown): void {
|
||||
response.writeHead(status, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"cache-control": "no-store",
|
||||
});
|
||||
response.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
const exampleRoot = dirname(fileURLToPath(import.meta.url));
|
||||
const webRoot = join(exampleRoot, "web");
|
||||
const capnwebBrowserModule = fileURLToPath(import.meta.resolve("capnweb"));
|
||||
const staticFiles = new Map([
|
||||
["/", { path: join(webRoot, "index.html"), type: "text/html; charset=utf-8" }],
|
||||
["/app.js", { path: join(webRoot, "app.js"), type: "text/javascript; charset=utf-8" }],
|
||||
["/styles.css", { path: join(webRoot, "styles.css"), type: "text/css; charset=utf-8" }],
|
||||
["/vendor/capnweb.js", { path: capnwebBrowserModule, type: "text/javascript; charset=utf-8" }],
|
||||
]);
|
||||
|
||||
const httpServer = createServer(async (request, response) => {
|
||||
try {
|
||||
const url = new URL(request.url ?? "/", `http://${request.headers.host ?? `${host}:${port}`}`);
|
||||
const staticFile = request.method === "GET" ? staticFiles.get(url.pathname) : undefined;
|
||||
if (staticFile) {
|
||||
await sendStatic(response, staticFile.path, staticFile.type);
|
||||
return;
|
||||
}
|
||||
sendJson(response, 404, { ok: false, error: "Not found" });
|
||||
} catch (error) {
|
||||
sendJson(response, 500, { ok: false, error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
const wsServer = new WebSocketServer({
|
||||
server: httpServer,
|
||||
path: "/api/rpc",
|
||||
maxPayload: 64 * 1024,
|
||||
perMessageDeflate: false,
|
||||
});
|
||||
wsServer.on("connection", (socket) => {
|
||||
let remoteClient: RpcStub<ClientBindingApi> | undefined;
|
||||
const connection = new PiServerConnection(() => {
|
||||
if (!remoteClient) throw new Error("Remote client binding is not ready");
|
||||
return remoteClient;
|
||||
});
|
||||
const session = new RpcSession<ClientBindingApi>(new NodeWebSocketTransport(socket), connection, {
|
||||
limits: { maxMessageSize: 64 * 1024 },
|
||||
});
|
||||
remoteClient = session.getRemoteMain();
|
||||
socket.once("close", () => connection[Symbol.dispose]());
|
||||
});
|
||||
|
||||
httpServer.listen(port, host, async () => {
|
||||
const auth = await models.checkAuth(providerId);
|
||||
console.log(`Cap'n Web capability spike listening at http://${host}:${port}`);
|
||||
console.log(`Model: ${modelSpec}${auth ? ` (${auth.source})` : " (provider auth not configured)"}`);
|
||||
console.log("Run the TUI with: npm run tui --prefix packages/coding-agent/examples/capability-capnweb-spike");
|
||||
});
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
||||
process.once(signal, () => {
|
||||
wsServer.close();
|
||||
httpServer.close(() => process.exit(0));
|
||||
});
|
||||
}
|
||||
136
packages/coding-agent/examples/capability-capnweb-spike/tui.ts
Normal file
136
packages/coding-agent/examples/capability-capnweb-spike/tui.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { createInterface } from "node:readline";
|
||||
import { newWebSocketRpcSession, type RpcPromise, RpcTarget } from "capnweb";
|
||||
import { Value } from "typebox/value";
|
||||
import {
|
||||
type ClientBindingApi,
|
||||
type NotifyInput,
|
||||
type NotifyOutput,
|
||||
notifyBinding,
|
||||
protocolVersion,
|
||||
type ServerApi,
|
||||
type ServerEvent,
|
||||
} from "./protocol.ts";
|
||||
|
||||
const baseUrl = process.env.PI_CAPNWEB_SPIKE_URL ?? "http://127.0.0.1:8788";
|
||||
const rpcUrl = new URL("/api/rpc", baseUrl);
|
||||
rpcUrl.protocol = rpcUrl.protocol === "https:" ? "wss:" : "ws:";
|
||||
const clientId = `tui-${randomUUID()}`;
|
||||
|
||||
const readline = createInterface({ input: process.stdin, output: process.stdout, prompt: "\x1b[1mYou ›\x1b[0m " });
|
||||
let assistantLineOpen = false;
|
||||
let busy = false;
|
||||
let runActive = false;
|
||||
|
||||
function printLine(text: string): void {
|
||||
if (assistantLineOpen) {
|
||||
process.stdout.write("\n");
|
||||
assistantLineOpen = false;
|
||||
}
|
||||
process.stdout.write(`\r\x1b[2K${text}\n`);
|
||||
}
|
||||
|
||||
function restorePromptIfIdle(): void {
|
||||
if (!runActive && !busy) readline.prompt(true);
|
||||
}
|
||||
|
||||
function handleEvent(event: ServerEvent): void {
|
||||
switch (event.type) {
|
||||
case "run_start":
|
||||
runActive = true;
|
||||
process.stdout.write("\r\x1b[2K");
|
||||
break;
|
||||
case "run_end":
|
||||
runActive = false;
|
||||
restorePromptIfIdle();
|
||||
break;
|
||||
case "assistant_start":
|
||||
if (assistantLineOpen) process.stdout.write("\n");
|
||||
process.stdout.write("\r\x1b[2K\x1b[36mPi ›\x1b[0m ");
|
||||
assistantLineOpen = true;
|
||||
break;
|
||||
case "assistant_delta":
|
||||
process.stdout.write(event.text);
|
||||
break;
|
||||
case "assistant_end":
|
||||
if (assistantLineOpen) process.stdout.write("\n");
|
||||
assistantLineOpen = false;
|
||||
break;
|
||||
case "tool_start":
|
||||
printLine(`\x1b[2m[tool: ${event.name}]\x1b[0m`);
|
||||
break;
|
||||
case "tool_end":
|
||||
break;
|
||||
case "error":
|
||||
printLine(`\x1b[31m${event.message}\x1b[0m`);
|
||||
restorePromptIfIdle();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
class TuiClientBindings extends RpcTarget implements ClientBindingApi {
|
||||
async event(event: ServerEvent): Promise<void> {
|
||||
handleEvent(event);
|
||||
}
|
||||
|
||||
async notify(input: NotifyInput): Promise<NotifyOutput> {
|
||||
if (!Value.Check(notifyBinding.input, input)) throw new Error("Invalid notify input");
|
||||
printLine(`\x1b[33m${input.message}\x1b[0m`);
|
||||
restorePromptIfIdle();
|
||||
return { displayed: true };
|
||||
}
|
||||
}
|
||||
|
||||
async function awaitRpc(call: RpcPromise<void>): Promise<void> {
|
||||
try {
|
||||
await call;
|
||||
} finally {
|
||||
call[Symbol.dispose]();
|
||||
}
|
||||
}
|
||||
|
||||
const server = newWebSocketRpcSession<ServerApi>(rpcUrl.toString(), new TuiClientBindings(), {
|
||||
limits: { maxMessageSize: 64 * 1024 },
|
||||
});
|
||||
server.onRpcBroken((error: unknown) => {
|
||||
printLine(`\x1b[31mRPC connection closed: ${error instanceof Error ? error.message : String(error)}\x1b[0m`);
|
||||
});
|
||||
await awaitRpc(
|
||||
server.connect({
|
||||
clientId,
|
||||
protocolVersion,
|
||||
bindings: [{ id: notifyBinding.id, version: notifyBinding.version }],
|
||||
}),
|
||||
);
|
||||
|
||||
console.log(`\x1b[2mConnected as ${clientId}. Use /notify <message> to invoke the binding without the LLM.\x1b[0m`);
|
||||
readline.prompt();
|
||||
|
||||
readline.on("line", (line) => {
|
||||
const text = line.trim();
|
||||
if (!text) {
|
||||
readline.prompt();
|
||||
return;
|
||||
}
|
||||
if (busy) {
|
||||
printLine("\x1b[33mThe shared session is busy.\x1b[0m");
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
const operation = text.startsWith("/notify ")
|
||||
? server.command("notify", { message: text.slice("/notify ".length) })
|
||||
: server.prompt(text);
|
||||
void awaitRpc(operation)
|
||||
.catch((error: unknown) => printLine(`\x1b[31m${error instanceof Error ? error.message : String(error)}\x1b[0m`))
|
||||
.finally(() => {
|
||||
busy = false;
|
||||
restorePromptIfIdle();
|
||||
});
|
||||
});
|
||||
|
||||
readline.once("close", () => {
|
||||
server[Symbol.dispose]();
|
||||
process.stdout.write("\n");
|
||||
});
|
||||
|
|
@ -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]());
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<link rel="icon" href="data:," />
|
||||
<title>Pi Cap'n Web capability spike</title>
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="page-grid" aria-hidden="true"></div>
|
||||
<header class="masthead">
|
||||
<div>
|
||||
<p class="eyebrow">Client binding / Cap'n Web</p>
|
||||
<h1>Pi, called remotely.</h1>
|
||||
</div>
|
||||
<div class="connection" data-state="connecting">
|
||||
<span class="connection-dot"></span>
|
||||
<span id="connection-label">Connecting</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="transcript" id="transcript" aria-live="polite" aria-label="Conversation">
|
||||
<div class="empty-state" id="empty-state">
|
||||
<p class="index">01</p>
|
||||
<p>Ask Pi to notify you, or call the client binding directly.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form class="composer" id="prompt-form">
|
||||
<label for="prompt">Prompt</label>
|
||||
<div class="input-row">
|
||||
<textarea id="prompt" name="prompt" rows="2" placeholder="Notify me through Cap'n Web" required></textarea>
|
||||
<button type="submit" id="send" disabled>Send</button>
|
||||
</div>
|
||||
<div class="composer-meta">
|
||||
<span>Enter to send · Shift+Enter for newline</span>
|
||||
<button type="button" class="text-button" id="test-notify" disabled>Call notify binding</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<div class="notification-stage" aria-live="assertive" aria-atomic="true">
|
||||
<div class="notification" id="notification" hidden></div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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%); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import type { RpcTransport } from "capnweb";
|
||||
import type WebSocket from "ws";
|
||||
|
||||
interface Receiver {
|
||||
resolve(message: string): void;
|
||||
reject(error: Error): void;
|
||||
}
|
||||
|
||||
/** Adapts the Node `ws` server socket to Cap'n Web without an unsafe type cast. */
|
||||
export class NodeWebSocketTransport implements RpcTransport {
|
||||
readonly #socket: WebSocket;
|
||||
readonly #messages: string[] = [];
|
||||
readonly #receivers: Receiver[] = [];
|
||||
#closedError: Error | undefined;
|
||||
|
||||
constructor(socket: WebSocket) {
|
||||
this.#socket = socket;
|
||||
socket.on("message", (data, isBinary) => {
|
||||
if (isBinary) {
|
||||
this.abort(new Error("Binary Cap'n Web frames are not supported"));
|
||||
return;
|
||||
}
|
||||
this.#deliver(data.toString());
|
||||
});
|
||||
socket.once("close", () => this.#fail(new Error("WebSocket closed")));
|
||||
socket.once("error", (error) => this.#fail(error));
|
||||
}
|
||||
|
||||
send(message: string): Promise<void> {
|
||||
if (this.#closedError || this.#socket.readyState !== 1) {
|
||||
return Promise.reject(this.#closedError ?? new Error("WebSocket is not open"));
|
||||
}
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
this.#socket.send(message, (error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
receive(): Promise<string> {
|
||||
const message = this.#messages.shift();
|
||||
if (message !== undefined) return Promise.resolve(message);
|
||||
if (this.#closedError) return Promise.reject(this.#closedError);
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
this.#receivers.push({ resolve, reject });
|
||||
});
|
||||
}
|
||||
|
||||
abort(reason: unknown): void {
|
||||
const error = reason instanceof Error ? reason : new Error(String(reason));
|
||||
this.#fail(error);
|
||||
if (this.#socket.readyState === 0 || this.#socket.readyState === 1) {
|
||||
this.#socket.close(1011, "RPC session aborted");
|
||||
}
|
||||
}
|
||||
|
||||
#deliver(message: string): void {
|
||||
const receiver = this.#receivers.shift();
|
||||
if (receiver) receiver.resolve(message);
|
||||
else this.#messages.push(message);
|
||||
}
|
||||
|
||||
#fail(error: Error): void {
|
||||
if (this.#closedError) return;
|
||||
this.#closedError = error;
|
||||
for (const receiver of this.#receivers.splice(0)) receiver.reject(error);
|
||||
}
|
||||
}
|
||||
79
packages/coding-agent/examples/capability-spike/README.md
Normal file
79
packages/coding-agent/examples/capability-spike/README.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# Client capability spike
|
||||
|
||||
A deliberately small server/client spike built on the new `AgentHarness` API. It demonstrates that a server extension can invoke one semantic client action without knowing whether the originating client is a TUI or a browser.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
notify extension ─┬─ notify tool ───────┐
|
||||
├─ notify command ────┤
|
||||
└─ response hook ─────┼─ pi.notify@1 ── originating client
|
||||
│
|
||||
AgentHarness ─── shared in-memory session
|
||||
```
|
||||
|
||||
The clients dynamically advertise `pi.notify@1` after connecting. Registrations are scoped to the SSE connection and disappear on disconnect. The model-facing tool, direct command, and assistant-response event hook call the same extension function and capability registry.
|
||||
|
||||
The transport is intentionally plain HTTP plus server-sent events:
|
||||
|
||||
- SSE carries session events and server-to-client action requests.
|
||||
- HTTP requests carry prompts, capability registration, and action results.
|
||||
- The server also serves the static web client, so a Cloudflare Worker is unnecessary for this local spike.
|
||||
|
||||
This only prototypes the capability boundary. Authentication, persistent sessions, client-controller leases, reconnect replay, and a production protocol are intentionally omitted.
|
||||
|
||||
## Run
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
OPENAI_API_KEY=... npx tsx packages/coding-agent/examples/capability-spike/server.ts
|
||||
```
|
||||
|
||||
The default model is `openai/gpt-5.4-nano`. Select another registered OpenAI or Anthropic model with:
|
||||
|
||||
```bash
|
||||
PI_SPIKE_MODEL=anthropic/claude-haiku-4-5 ANTHROPIC_API_KEY=... \
|
||||
npx tsx packages/coding-agent/examples/capability-spike/server.ts
|
||||
```
|
||||
|
||||
The server starts without provider credentials, which is enough to exercise direct capability calls. LLM prompts require the selected provider's environment credential.
|
||||
|
||||
Open [http://127.0.0.1:8787](http://127.0.0.1:8787) for the web client.
|
||||
|
||||
In another terminal, run the TUI client:
|
||||
|
||||
```bash
|
||||
npx tsx packages/coding-agent/examples/capability-spike/tui.ts
|
||||
```
|
||||
|
||||
Try:
|
||||
|
||||
```text
|
||||
/notify TUI capability confirmed.
|
||||
```
|
||||
|
||||
Or prompt the model:
|
||||
|
||||
```text
|
||||
Notify me that the capability RPC works, then briefly explain what happened.
|
||||
```
|
||||
|
||||
When both clients are connected, a notification requested by a prompt is delivered only to the client that submitted that prompt.
|
||||
|
||||
For a deterministic extension-initiated action that does not use the tool wrapper, ask:
|
||||
|
||||
```text
|
||||
What is the capital of France?
|
||||
```
|
||||
|
||||
The server system prompt makes the assistant answer exactly `Paris`. The extension observes the completed assistant message and invokes `pi.notify@1` with `❤️` on the originating client.
|
||||
|
||||
## Files
|
||||
|
||||
- `server.ts`: HTTP/SSE server and `AgentHarness` session
|
||||
- `extension.ts`: server extension with the command, model-facing tool, and assistant-response hook
|
||||
- `capability-registry.ts`: connection-scoped action registration and invocation
|
||||
- `protocol.ts`: typed `pi.notify@1` input/output contract and wire events
|
||||
- `tui.ts`: readline client whose notify implementation prints yellow text
|
||||
- `web/`: static browser client with the spinning notification animation
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import type { Static, TSchema } from "typebox";
|
||||
import { Value } from "typebox/value";
|
||||
import type { ActionRegistration, ActionResultMessage, ClientAction, ServerEvent } from "./protocol.ts";
|
||||
|
||||
interface ClientConnection {
|
||||
id: string;
|
||||
actions: Map<string, number>;
|
||||
send(event: ServerEvent): void;
|
||||
}
|
||||
|
||||
interface PendingInvocation {
|
||||
clientId: string;
|
||||
action: ClientAction<TSchema, TSchema>;
|
||||
resolve(value: unknown): void;
|
||||
reject(error: Error): void;
|
||||
timer: NodeJS.Timeout;
|
||||
removeAbortListener?: () => void;
|
||||
}
|
||||
|
||||
export class CapabilityError extends Error {
|
||||
public code:
|
||||
| "client_unavailable"
|
||||
| "action_unavailable"
|
||||
| "invalid_input"
|
||||
| "invalid_output"
|
||||
| "aborted"
|
||||
| "timeout"
|
||||
| "remote_error";
|
||||
|
||||
constructor(
|
||||
code:
|
||||
| "client_unavailable"
|
||||
| "action_unavailable"
|
||||
| "invalid_input"
|
||||
| "invalid_output"
|
||||
| "aborted"
|
||||
| "timeout"
|
||||
| "remote_error",
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "CapabilityError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export class CapabilityRegistry {
|
||||
private readonly clients = new Map<string, ClientConnection>();
|
||||
private readonly pending = new Map<string, PendingInvocation>();
|
||||
|
||||
connect(clientId: string, send: (event: ServerEvent) => void): () => void {
|
||||
this.disconnect(clientId, "Client reconnected");
|
||||
const connection: ClientConnection = { id: clientId, actions: new Map(), send };
|
||||
this.clients.set(clientId, connection);
|
||||
connection.send({ type: "ready", clientId });
|
||||
|
||||
return () => {
|
||||
if (this.clients.get(clientId) === connection) {
|
||||
this.disconnect(clientId, "Client disconnected");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
hasClient(clientId: string): boolean {
|
||||
return this.clients.has(clientId);
|
||||
}
|
||||
|
||||
register(clientId: string, actions: ActionRegistration[]): void {
|
||||
const client = this.clients.get(clientId);
|
||||
if (!client) {
|
||||
throw new CapabilityError("client_unavailable", `Client ${clientId} is not connected`);
|
||||
}
|
||||
client.actions = new Map(actions.map((action) => [action.id, action.version]));
|
||||
client.send({ type: "capabilities_updated", actions });
|
||||
}
|
||||
|
||||
async invoke<TInputSchema extends TSchema, TOutputSchema extends TSchema>(
|
||||
clientId: string,
|
||||
action: ClientAction<TInputSchema, TOutputSchema>,
|
||||
input: unknown,
|
||||
options: { signal?: AbortSignal; timeoutMs?: number } = {},
|
||||
): Promise<Static<TOutputSchema>> {
|
||||
if (!Value.Check(action.input, input)) {
|
||||
throw new CapabilityError("invalid_input", `Input does not match ${action.id}@${action.version}`);
|
||||
}
|
||||
const client = this.clients.get(clientId);
|
||||
if (!client) {
|
||||
throw new CapabilityError("client_unavailable", `Client ${clientId} is not connected`);
|
||||
}
|
||||
if (client.actions.get(action.id) !== action.version) {
|
||||
throw new CapabilityError(
|
||||
"action_unavailable",
|
||||
`Client ${clientId} does not provide ${action.id}@${action.version}`,
|
||||
);
|
||||
}
|
||||
if (options.signal?.aborted) {
|
||||
throw new CapabilityError("aborted", `${action.id} was aborted`);
|
||||
}
|
||||
|
||||
const requestId = randomUUID();
|
||||
const timeoutMs = options.timeoutMs ?? 10_000;
|
||||
const result = new Promise<unknown>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.rejectInvocation(
|
||||
requestId,
|
||||
new CapabilityError("timeout", `${action.id} timed out after ${timeoutMs}ms`),
|
||||
);
|
||||
client.send({ type: "action_cancel", requestId });
|
||||
}, timeoutMs);
|
||||
const pending: PendingInvocation = {
|
||||
clientId,
|
||||
action,
|
||||
resolve,
|
||||
reject,
|
||||
timer,
|
||||
};
|
||||
if (options.signal) {
|
||||
const onAbort = () => {
|
||||
this.rejectInvocation(requestId, new CapabilityError("aborted", `${action.id} was aborted`));
|
||||
client.send({ type: "action_cancel", requestId });
|
||||
};
|
||||
options.signal.addEventListener("abort", onAbort, { once: true });
|
||||
pending.removeAbortListener = () => options.signal?.removeEventListener("abort", onAbort);
|
||||
}
|
||||
this.pending.set(requestId, pending);
|
||||
});
|
||||
|
||||
client.send({
|
||||
type: "action_request",
|
||||
requestId,
|
||||
action: { id: action.id, version: action.version },
|
||||
input,
|
||||
});
|
||||
const output = await result;
|
||||
if (!Value.Check(action.output, output)) {
|
||||
throw new CapabilityError("invalid_output", `Output does not match ${action.id}@${action.version}`);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
handleResult(requestId: string, message: ActionResultMessage): void {
|
||||
const pending = this.pending.get(requestId);
|
||||
if (!pending) return;
|
||||
if (pending.clientId !== message.clientId) {
|
||||
throw new CapabilityError("remote_error", `Client ${message.clientId} cannot answer request ${requestId}`);
|
||||
}
|
||||
this.pending.delete(requestId);
|
||||
clearTimeout(pending.timer);
|
||||
pending.removeAbortListener?.();
|
||||
if (!message.ok) {
|
||||
pending.reject(new CapabilityError("remote_error", message.error || `${pending.action.id} failed`));
|
||||
return;
|
||||
}
|
||||
if (!Value.Check(pending.action.output, message.result)) {
|
||||
pending.reject(
|
||||
new CapabilityError(
|
||||
"invalid_output",
|
||||
`Output does not match ${pending.action.id}@${pending.action.version}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
pending.resolve(message.result);
|
||||
}
|
||||
|
||||
broadcast(event: ServerEvent): void {
|
||||
for (const client of this.clients.values()) {
|
||||
client.send(event);
|
||||
}
|
||||
}
|
||||
|
||||
private rejectInvocation(requestId: string, error: Error): void {
|
||||
const pending = this.pending.get(requestId);
|
||||
if (!pending) return;
|
||||
this.pending.delete(requestId);
|
||||
clearTimeout(pending.timer);
|
||||
pending.removeAbortListener?.();
|
||||
pending.reject(error);
|
||||
}
|
||||
|
||||
private disconnect(clientId: string, reason: string): void {
|
||||
if (!this.clients.delete(clientId)) return;
|
||||
for (const [requestId, pending] of this.pending) {
|
||||
if (pending.clientId === clientId) {
|
||||
this.rejectInvocation(requestId, new CapabilityError("client_unavailable", reason));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
86
packages/coding-agent/examples/capability-spike/extension.ts
Normal file
86
packages/coding-agent/examples/capability-spike/extension.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import type { AgentHarnessEvent, AgentHarnessTool } from "@earendil-works/pi-agent-core";
|
||||
import { contentText } from "@earendil-works/pi-ai";
|
||||
import { type Static, type TSchema, Type } from "typebox";
|
||||
import { type NotifyOutput, notifyAction } from "./protocol.ts";
|
||||
|
||||
export interface ClientActions {
|
||||
invoke<TInputSchema extends TSchema, TOutputSchema extends TSchema>(
|
||||
action: {
|
||||
id: string;
|
||||
version: number;
|
||||
input: TInputSchema;
|
||||
output: TOutputSchema;
|
||||
},
|
||||
input: unknown,
|
||||
options?: { signal?: AbortSignal; timeoutMs?: number },
|
||||
): Promise<Static<TOutputSchema>>;
|
||||
}
|
||||
|
||||
export interface NotifyExtensionContext {
|
||||
clientActions: ClientActions;
|
||||
}
|
||||
|
||||
export type ServerCommand = (input: unknown, context: NotifyExtensionContext, signal?: AbortSignal) => Promise<unknown>;
|
||||
|
||||
export type MessageEndEvent = Extract<AgentHarnessEvent, { type: "message_end" }>;
|
||||
|
||||
export type MessageEndHandler = (
|
||||
event: MessageEndEvent,
|
||||
context: NotifyExtensionContext,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<void> | void;
|
||||
|
||||
export interface ServerExtensionApi {
|
||||
registerTool(tool: AgentHarnessTool<NotifyExtensionContext>): void;
|
||||
registerCommand(name: string, command: ServerCommand): void;
|
||||
on(type: "message_end", handler: MessageEndHandler): void;
|
||||
}
|
||||
|
||||
async function notifyClient(
|
||||
input: unknown,
|
||||
context: NotifyExtensionContext,
|
||||
signal?: AbortSignal,
|
||||
): Promise<NotifyOutput> {
|
||||
if (
|
||||
typeof input !== "object" ||
|
||||
input === null ||
|
||||
!("message" in input) ||
|
||||
typeof input.message !== "string" ||
|
||||
input.message.trim().length === 0
|
||||
) {
|
||||
throw new Error("notify requires a non-empty message");
|
||||
}
|
||||
return context.clientActions.invoke(notifyAction, { message: input.message.trim() }, { signal });
|
||||
}
|
||||
|
||||
const notifyParameters = Type.Object(
|
||||
{
|
||||
message: Type.String({ description: "Notification text", minLength: 1, maxLength: 500 }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export function notifyExtension(pi: ServerExtensionApi): void {
|
||||
pi.registerCommand("notify", notifyClient);
|
||||
pi.on("message_end", async (event, context, signal) => {
|
||||
if (event.message.role !== "assistant") return;
|
||||
if (contentText(event.message.content, "").trim() === "Paris") {
|
||||
await notifyClient({ message: "❤️" }, context, signal);
|
||||
}
|
||||
});
|
||||
pi.registerTool({
|
||||
name: "notify",
|
||||
label: "Notify client",
|
||||
description: "Show a brief notification on the client that submitted the current prompt.",
|
||||
parameters: notifyParameters,
|
||||
async execute(_toolCallId, params, signal, _onUpdate, context) {
|
||||
const input = typeof params === "object" && params !== null ? params : {};
|
||||
const message = "message" in input && typeof input.message === "string" ? input.message : "";
|
||||
await notifyClient({ message }, context, signal);
|
||||
return {
|
||||
content: [{ type: "text", text: `Notified the originating client: ${message}` }],
|
||||
details: { displayed: true },
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
55
packages/coding-agent/examples/capability-spike/protocol.ts
Normal file
55
packages/coding-agent/examples/capability-spike/protocol.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { type Static, type TSchema, Type } from "typebox";
|
||||
|
||||
export interface ClientAction<TInputSchema extends TSchema, TOutputSchema extends TSchema> {
|
||||
id: string;
|
||||
version: number;
|
||||
input: TInputSchema;
|
||||
output: TOutputSchema;
|
||||
}
|
||||
|
||||
export const notifyAction = {
|
||||
id: "pi.notify",
|
||||
version: 1,
|
||||
input: Type.Object(
|
||||
{
|
||||
message: Type.String({ minLength: 1, maxLength: 500 }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
output: Type.Object(
|
||||
{
|
||||
displayed: Type.Literal(true),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
} satisfies ClientAction<TSchema, TSchema>;
|
||||
|
||||
export type NotifyInput = Static<typeof notifyAction.input>;
|
||||
export type NotifyOutput = Static<typeof notifyAction.output>;
|
||||
|
||||
export interface ActionRegistration {
|
||||
id: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export type ServerEvent =
|
||||
| { type: "ready"; clientId: string }
|
||||
| { type: "capabilities_updated"; actions: ActionRegistration[] }
|
||||
| { type: "user_prompt"; clientId: string; text: string }
|
||||
| { type: "run_start" }
|
||||
| { type: "run_end" }
|
||||
| { type: "assistant_start" }
|
||||
| { type: "assistant_delta"; text: string }
|
||||
| { type: "assistant_end" }
|
||||
| { type: "tool_start"; name: string }
|
||||
| { type: "tool_end"; name: string; isError: boolean }
|
||||
| { type: "error"; message: string }
|
||||
| { type: "action_request"; requestId: string; action: ActionRegistration; input: unknown }
|
||||
| { type: "action_cancel"; requestId: string };
|
||||
|
||||
export interface ActionResultMessage {
|
||||
clientId: string;
|
||||
ok: boolean;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
317
packages/coding-agent/examples/capability-spike/server.ts
Normal file
317
packages/coding-agent/examples/capability-spike/server.ts
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
import { readFile } from "node:fs/promises";
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
AgentHarness,
|
||||
type AgentHarnessEvent,
|
||||
type AgentHarnessTool,
|
||||
InMemorySessionStorage,
|
||||
Session,
|
||||
} from "@earendil-works/pi-agent-core";
|
||||
import { createModels, type Model } from "@earendil-works/pi-ai";
|
||||
import { anthropicProvider } from "@earendil-works/pi-ai/providers/anthropic";
|
||||
import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";
|
||||
import { CapabilityError, CapabilityRegistry } from "./capability-registry.ts";
|
||||
import {
|
||||
type MessageEndHandler,
|
||||
type NotifyExtensionContext,
|
||||
notifyExtension,
|
||||
type ServerCommand,
|
||||
type ServerExtensionApi,
|
||||
} from "./extension.ts";
|
||||
import type { ActionRegistration, ActionResultMessage, ServerEvent } from "./protocol.ts";
|
||||
|
||||
const host = process.env.PI_SPIKE_HOST ?? "127.0.0.1";
|
||||
const port = Number(process.env.PI_SPIKE_PORT ?? "8787");
|
||||
const modelSpec = process.env.PI_SPIKE_MODEL ?? "openai/gpt-5.4-nano";
|
||||
const [providerId, modelId] = modelSpec.split("/", 2);
|
||||
if (!providerId || !modelId) {
|
||||
throw new Error(`PI_SPIKE_MODEL must use provider/model syntax, received ${modelSpec}`);
|
||||
}
|
||||
|
||||
const models = createModels();
|
||||
models.setProvider(openaiProvider());
|
||||
models.setProvider(anthropicProvider());
|
||||
function createFallbackModel(provider: string, id: string): Model<"openai-responses" | "anthropic-messages"> {
|
||||
const common = {
|
||||
id,
|
||||
name: id,
|
||||
reasoning: false,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 16_384,
|
||||
};
|
||||
if (provider === "openai") {
|
||||
return {
|
||||
...common,
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
input: ["text"],
|
||||
};
|
||||
}
|
||||
if (provider === "anthropic") {
|
||||
return {
|
||||
...common,
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
input: ["text"],
|
||||
};
|
||||
}
|
||||
throw new Error(`Unsupported provider ${provider}. This spike registers openai and anthropic.`);
|
||||
}
|
||||
const model = models.getModel(providerId, modelId) ?? createFallbackModel(providerId, modelId);
|
||||
|
||||
const registry = new CapabilityRegistry();
|
||||
const tools: AgentHarnessTool<NotifyExtensionContext>[] = [];
|
||||
const commands = new Map<string, ServerCommand>();
|
||||
const messageEndHandlers: MessageEndHandler[] = [];
|
||||
const extensionApi: ServerExtensionApi = {
|
||||
registerTool(tool) {
|
||||
tools.push(tool);
|
||||
},
|
||||
registerCommand(name, command) {
|
||||
commands.set(name, command);
|
||||
},
|
||||
on(type, handler) {
|
||||
if (type === "message_end") messageEndHandlers.push(handler);
|
||||
},
|
||||
};
|
||||
notifyExtension(extensionApi);
|
||||
|
||||
let activeOriginClientId: string | undefined;
|
||||
let promptInFlight = false;
|
||||
|
||||
function contextFor(clientId: string): NotifyExtensionContext {
|
||||
return {
|
||||
clientActions: {
|
||||
invoke: (action, input, options) => registry.invoke(clientId, action, input, options),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const harness = new AgentHarness({
|
||||
session: new Session(new InMemorySessionStorage()),
|
||||
models,
|
||||
model,
|
||||
thinkingLevel: "off",
|
||||
tools,
|
||||
toolContext: () => {
|
||||
if (!activeOriginClientId) throw new Error("No originating client is bound to this turn");
|
||||
return contextFor(activeOriginClientId);
|
||||
},
|
||||
systemPrompt: [
|
||||
"You are a concise assistant.",
|
||||
"When asked for the capital of France, respond with exactly Paris and nothing else.",
|
||||
"When the user asks for a notification, call the notify tool with the exact message they want shown.",
|
||||
].join(" "),
|
||||
});
|
||||
|
||||
harness.subscribe(async (event, signal) => {
|
||||
for (const wireEvent of toServerEvents(event)) registry.broadcast(wireEvent);
|
||||
if (event.type === "message_end" && messageEndHandlers.length > 0) {
|
||||
if (!activeOriginClientId) throw new Error("No originating client is bound to this assistant response");
|
||||
const context = contextFor(activeOriginClientId);
|
||||
for (const handler of messageEndHandlers) await handler(event, context, signal);
|
||||
}
|
||||
});
|
||||
|
||||
function toServerEvents(event: AgentHarnessEvent): ServerEvent[] {
|
||||
switch (event.type) {
|
||||
case "agent_start":
|
||||
return [{ type: "run_start" }];
|
||||
case "agent_end":
|
||||
return [{ type: "run_end" }];
|
||||
case "message_start":
|
||||
return event.message.role === "assistant" ? [{ type: "assistant_start" }] : [];
|
||||
case "message_update":
|
||||
return event.assistantMessageEvent.type === "text_delta"
|
||||
? [{ type: "assistant_delta", text: event.assistantMessageEvent.delta }]
|
||||
: [];
|
||||
case "message_end":
|
||||
if (event.message.role !== "assistant") return [];
|
||||
return [
|
||||
{ type: "assistant_end" },
|
||||
...(event.message.errorMessage ? [{ type: "error" as const, message: event.message.errorMessage }] : []),
|
||||
];
|
||||
case "tool_execution_start":
|
||||
return [{ type: "tool_start", name: event.toolName }];
|
||||
case "tool_execution_end":
|
||||
return [{ type: "tool_end", name: event.toolName, isError: event.isError }];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function sendJson(response: ServerResponse, status: number, body: unknown): void {
|
||||
response.writeHead(status, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"cache-control": "no-store",
|
||||
});
|
||||
response.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
async function readJson(request: IncomingMessage): Promise<unknown> {
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
for await (const chunk of request) {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
size += buffer.length;
|
||||
if (size > 64 * 1024) throw new Error("Request body exceeds 64 KiB");
|
||||
chunks.push(buffer);
|
||||
}
|
||||
if (chunks.length === 0) return {};
|
||||
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
}
|
||||
|
||||
function objectRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function requireClientId(value: unknown): string {
|
||||
if (typeof value !== "string" || !/^[a-zA-Z0-9_-]{1,80}$/.test(value)) {
|
||||
throw new Error("Invalid clientId");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseActions(value: unknown): ActionRegistration[] {
|
||||
if (!Array.isArray(value)) throw new Error("actions must be an array");
|
||||
return value.map((candidate) => {
|
||||
const action = objectRecord(candidate);
|
||||
if (!action || typeof action.id !== "string" || typeof action.version !== "number") {
|
||||
throw new Error("Invalid action registration");
|
||||
}
|
||||
return { id: action.id, version: action.version };
|
||||
});
|
||||
}
|
||||
|
||||
function errorStatus(error: unknown): number {
|
||||
if (!(error instanceof CapabilityError)) return 400;
|
||||
return error.code === "client_unavailable" || error.code === "action_unavailable" ? 409 : 400;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
const webRoot = join(dirname(fileURLToPath(import.meta.url)), "web");
|
||||
const staticFiles = new Map([
|
||||
["/", { path: join(webRoot, "index.html"), type: "text/html; charset=utf-8" }],
|
||||
["/app.js", { path: join(webRoot, "app.js"), type: "text/javascript; charset=utf-8" }],
|
||||
["/styles.css", { path: join(webRoot, "styles.css"), type: "text/css; charset=utf-8" }],
|
||||
]);
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
try {
|
||||
const url = new URL(request.url ?? "/", `http://${request.headers.host ?? `${host}:${port}`}`);
|
||||
const staticFile = request.method === "GET" ? staticFiles.get(url.pathname) : undefined;
|
||||
if (staticFile) {
|
||||
response.writeHead(200, { "content-type": staticFile.type, "cache-control": "no-store" });
|
||||
response.end(await readFile(staticFile.path));
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/api/events") {
|
||||
const clientId = requireClientId(url.searchParams.get("clientId"));
|
||||
response.writeHead(200, {
|
||||
"content-type": "text/event-stream; charset=utf-8",
|
||||
"cache-control": "no-cache, no-transform",
|
||||
connection: "keep-alive",
|
||||
});
|
||||
response.flushHeaders();
|
||||
const disconnect = registry.connect(clientId, (event) => {
|
||||
response.write(`data: ${JSON.stringify(event)}\n\n`);
|
||||
});
|
||||
const heartbeat = setInterval(() => response.write(": heartbeat\n\n"), 15_000);
|
||||
request.once("close", () => {
|
||||
clearInterval(heartbeat);
|
||||
disconnect();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
request.method === "PUT" &&
|
||||
url.pathname.startsWith("/api/clients/") &&
|
||||
url.pathname.endsWith("/capabilities")
|
||||
) {
|
||||
const clientId = requireClientId(url.pathname.slice("/api/clients/".length, -"/capabilities".length));
|
||||
const body = objectRecord(await readJson(request));
|
||||
registry.register(clientId, parseActions(body?.actions));
|
||||
sendJson(response, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname.startsWith("/api/action-results/")) {
|
||||
const requestId = url.pathname.slice("/api/action-results/".length);
|
||||
const body = objectRecord(await readJson(request));
|
||||
const message: ActionResultMessage = {
|
||||
clientId: requireClientId(body?.clientId),
|
||||
ok: body?.ok === true,
|
||||
result: body?.result,
|
||||
error: typeof body?.error === "string" ? body.error : undefined,
|
||||
};
|
||||
registry.handleResult(requestId, message);
|
||||
sendJson(response, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/api/prompt") {
|
||||
const body = objectRecord(await readJson(request));
|
||||
const clientId = requireClientId(body?.clientId);
|
||||
if (!registry.hasClient(clientId)) throw new CapabilityError("client_unavailable", "Client is not connected");
|
||||
if (typeof body?.text !== "string" || body.text.trim().length === 0) throw new Error("Prompt is required");
|
||||
if (promptInFlight) {
|
||||
sendJson(response, 409, { ok: false, error: "The shared session is already processing a prompt" });
|
||||
return;
|
||||
}
|
||||
promptInFlight = true;
|
||||
activeOriginClientId = clientId;
|
||||
registry.broadcast({ type: "user_prompt", clientId, text: body.text.trim() });
|
||||
try {
|
||||
const assistant = await harness.prompt(body.text.trim());
|
||||
sendJson(response, 200, { ok: assistant.stopReason !== "error", error: assistant.errorMessage });
|
||||
} finally {
|
||||
activeOriginClientId = undefined;
|
||||
promptInFlight = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname.startsWith("/api/commands/")) {
|
||||
const name = url.pathname.slice("/api/commands/".length);
|
||||
const command = commands.get(name);
|
||||
if (!command) {
|
||||
sendJson(response, 404, { ok: false, error: `Unknown command ${name}` });
|
||||
return;
|
||||
}
|
||||
const body = objectRecord(await readJson(request));
|
||||
const clientId = requireClientId(body?.clientId);
|
||||
const result = await command(body, contextFor(clientId));
|
||||
sendJson(response, 200, { ok: true, result });
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(response, 404, { ok: false, error: "Not found" });
|
||||
} catch (error) {
|
||||
sendJson(response, errorStatus(error), { ok: false, error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(port, host, async () => {
|
||||
const auth = await models.checkAuth(providerId);
|
||||
console.log(`Capability spike listening at http://${host}:${port}`);
|
||||
console.log(`Model: ${modelSpec}${auth ? ` (${auth.source})` : " (provider auth not configured)"}`);
|
||||
console.log("Open the web URL or run: npx tsx packages/coding-agent/examples/capability-spike/tui.ts");
|
||||
});
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
||||
process.once(signal, () => {
|
||||
server.close(() => process.exit(0));
|
||||
});
|
||||
}
|
||||
182
packages/coding-agent/examples/capability-spike/tui.ts
Normal file
182
packages/coding-agent/examples/capability-spike/tui.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { createInterface } from "node:readline";
|
||||
import type { ActionResultMessage, ServerEvent } from "./protocol.ts";
|
||||
import { notifyAction } from "./protocol.ts";
|
||||
|
||||
const baseUrl = process.env.PI_SPIKE_URL ?? "http://127.0.0.1:8787";
|
||||
const clientId = `tui-${randomUUID()}`;
|
||||
const eventsAbort = new AbortController();
|
||||
|
||||
interface JsonResponse {
|
||||
ok?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function request(path: string, init: RequestInit): Promise<unknown> {
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
...init,
|
||||
headers: { "content-type": "application/json", ...init.headers },
|
||||
});
|
||||
const body = (await response.json()) as JsonResponse;
|
||||
if (!response.ok || body.ok === false) throw new Error(body.error ?? `Request failed with ${response.status}`);
|
||||
return body;
|
||||
}
|
||||
|
||||
function isServerEvent(value: unknown): value is ServerEvent {
|
||||
return typeof value === "object" && value !== null && "type" in value && typeof value.type === "string";
|
||||
}
|
||||
|
||||
const eventResponse = await fetch(`${baseUrl}/api/events?clientId=${encodeURIComponent(clientId)}`, {
|
||||
headers: { accept: "text/event-stream" },
|
||||
signal: eventsAbort.signal,
|
||||
});
|
||||
if (!eventResponse.ok || !eventResponse.body) {
|
||||
throw new Error(`Could not connect to ${baseUrl}: ${eventResponse.status} ${eventResponse.statusText}`);
|
||||
}
|
||||
|
||||
await request(`/api/clients/${encodeURIComponent(clientId)}/capabilities`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ actions: [{ id: notifyAction.id, version: notifyAction.version }] }),
|
||||
});
|
||||
|
||||
const readline = createInterface({ input: process.stdin, output: process.stdout, prompt: "\x1b[1mYou ›\x1b[0m " });
|
||||
let assistantLineOpen = false;
|
||||
let busy = false;
|
||||
let runActive = false;
|
||||
|
||||
function printLine(text: string): void {
|
||||
if (assistantLineOpen) {
|
||||
process.stdout.write("\n");
|
||||
assistantLineOpen = false;
|
||||
}
|
||||
process.stdout.write(`\r\x1b[2K${text}\n`);
|
||||
}
|
||||
|
||||
function restorePromptIfIdle(): void {
|
||||
if (!runActive && !busy) readline.prompt(true);
|
||||
}
|
||||
|
||||
async function answerAction(event: Extract<ServerEvent, { type: "action_request" }>): Promise<void> {
|
||||
if (event.action.id !== notifyAction.id || event.action.version !== notifyAction.version) {
|
||||
await postActionResult(event.requestId, { clientId, ok: false, error: "Unsupported action" });
|
||||
return;
|
||||
}
|
||||
const input = typeof event.input === "object" && event.input !== null ? event.input : undefined;
|
||||
const message = input && "message" in input && typeof input.message === "string" ? input.message : undefined;
|
||||
if (!message) {
|
||||
await postActionResult(event.requestId, { clientId, ok: false, error: "Invalid notify input" });
|
||||
return;
|
||||
}
|
||||
printLine(`\x1b[33m${message}\x1b[0m`);
|
||||
await postActionResult(event.requestId, { clientId, ok: true, result: { displayed: true } });
|
||||
restorePromptIfIdle();
|
||||
}
|
||||
|
||||
async function postActionResult(requestId: string, result: ActionResultMessage): Promise<void> {
|
||||
await request(`/api/action-results/${encodeURIComponent(requestId)}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(result),
|
||||
});
|
||||
}
|
||||
|
||||
function handleEvent(event: ServerEvent): void {
|
||||
switch (event.type) {
|
||||
case "run_start":
|
||||
runActive = true;
|
||||
process.stdout.write("\r\x1b[2K");
|
||||
break;
|
||||
case "run_end":
|
||||
runActive = false;
|
||||
restorePromptIfIdle();
|
||||
break;
|
||||
case "assistant_start":
|
||||
if (assistantLineOpen) process.stdout.write("\n");
|
||||
process.stdout.write("\r\x1b[2K\x1b[36mPi ›\x1b[0m ");
|
||||
assistantLineOpen = true;
|
||||
break;
|
||||
case "assistant_delta":
|
||||
process.stdout.write(event.text);
|
||||
break;
|
||||
case "assistant_end":
|
||||
if (assistantLineOpen) process.stdout.write("\n");
|
||||
assistantLineOpen = false;
|
||||
break;
|
||||
case "tool_start":
|
||||
printLine(`\x1b[2m[tool: ${event.name}]\x1b[0m`);
|
||||
break;
|
||||
case "tool_end":
|
||||
break;
|
||||
case "error":
|
||||
printLine(`\x1b[31m${event.message}\x1b[0m`);
|
||||
restorePromptIfIdle();
|
||||
break;
|
||||
case "action_request":
|
||||
void answerAction(event).catch((error: unknown) => printLine(`\x1b[31m${String(error)}\x1b[0m`));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async function consumeEvents(): Promise<void> {
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
for await (const chunk of eventResponse.body!) {
|
||||
buffer += decoder.decode(chunk, { stream: true });
|
||||
let boundary = buffer.indexOf("\n\n");
|
||||
while (boundary !== -1) {
|
||||
const frame = buffer.slice(0, boundary);
|
||||
buffer = buffer.slice(boundary + 2);
|
||||
for (const line of frame.split("\n")) {
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
const parsed: unknown = JSON.parse(line.slice(6));
|
||||
if (isServerEvent(parsed)) handleEvent(parsed);
|
||||
}
|
||||
boundary = buffer.indexOf("\n\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void consumeEvents().catch((error: unknown) => {
|
||||
if (!eventsAbort.signal.aborted) {
|
||||
printLine(`\x1b[31mEvent stream closed: ${String(error)}\x1b[0m`);
|
||||
runActive = false;
|
||||
restorePromptIfIdle();
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`\x1b[2mConnected as ${clientId}. Use /notify <message> to invoke the capability without the LLM.\x1b[0m`);
|
||||
readline.prompt();
|
||||
|
||||
readline.on("line", (line) => {
|
||||
const text = line.trim();
|
||||
if (!text) {
|
||||
readline.prompt();
|
||||
return;
|
||||
}
|
||||
if (busy) {
|
||||
printLine("\x1b[33mThe shared session is busy.\x1b[0m");
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
const operation = text.startsWith("/notify ")
|
||||
? request("/api/commands/notify", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ clientId, message: text.slice("/notify ".length) }),
|
||||
})
|
||||
: request("/api/prompt", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ clientId, text }),
|
||||
});
|
||||
void operation
|
||||
.catch((error: unknown) => printLine(`\x1b[31m${error instanceof Error ? error.message : String(error)}\x1b[0m`))
|
||||
.finally(() => {
|
||||
busy = false;
|
||||
restorePromptIfIdle();
|
||||
});
|
||||
});
|
||||
|
||||
readline.once("close", () => {
|
||||
eventsAbort.abort();
|
||||
process.stdout.write("\n");
|
||||
});
|
||||
176
packages/coding-agent/examples/capability-spike/web/app.js
Normal file
176
packages/coding-agent/examples/capability-spike/web/app.js
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
const notifyAction = { id: "pi.notify", version: 1 };
|
||||
const clientId = sessionStorage.getItem("pi-capability-client") ?? `web-${crypto.randomUUID()}`;
|
||||
sessionStorage.setItem("pi-capability-client", clientId);
|
||||
|
||||
const connection = document.querySelector(".connection");
|
||||
const connectionLabel = document.querySelector("#connection-label");
|
||||
const transcript = document.querySelector("#transcript");
|
||||
const emptyState = document.querySelector("#empty-state");
|
||||
const form = document.querySelector("#prompt-form");
|
||||
const prompt = document.querySelector("#prompt");
|
||||
const send = document.querySelector("#send");
|
||||
const testNotify = document.querySelector("#test-notify");
|
||||
const notification = document.querySelector("#notification");
|
||||
|
||||
let connected = false;
|
||||
let busy = false;
|
||||
let assistantContent;
|
||||
let notificationTimer;
|
||||
|
||||
function setConnection(state, label) {
|
||||
connection.dataset.state = state;
|
||||
connectionLabel.textContent = label;
|
||||
connected = state === "connected";
|
||||
send.disabled = !connected || busy;
|
||||
testNotify.disabled = !connected || busy;
|
||||
}
|
||||
|
||||
function appendMessage(role, text, className = role.toLowerCase()) {
|
||||
emptyState?.remove();
|
||||
const message = document.createElement("article");
|
||||
message.className = `message ${className}`;
|
||||
const label = document.createElement("p");
|
||||
label.className = "role";
|
||||
label.textContent = role;
|
||||
const content = document.createElement("p");
|
||||
content.className = "content";
|
||||
content.textContent = text;
|
||||
message.append(label, content);
|
||||
transcript.append(message);
|
||||
transcript.scrollTop = transcript.scrollHeight;
|
||||
return content;
|
||||
}
|
||||
|
||||
function showNotification(message) {
|
||||
clearTimeout(notificationTimer);
|
||||
notification.hidden = false;
|
||||
notification.textContent = message;
|
||||
notification.classList.remove("is-flying");
|
||||
void notification.offsetWidth;
|
||||
notification.classList.add("is-flying");
|
||||
notificationTimer = setTimeout(() => {
|
||||
notification.hidden = true;
|
||||
notification.classList.remove("is-flying");
|
||||
}, 2400);
|
||||
}
|
||||
|
||||
async function api(path, options) {
|
||||
const response = await fetch(path, {
|
||||
...options,
|
||||
headers: { "content-type": "application/json", ...options?.headers },
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok || body.ok === false) throw new Error(body.error ?? `Request failed with ${response.status}`);
|
||||
return body;
|
||||
}
|
||||
|
||||
async function registerCapabilities() {
|
||||
await api(`/api/clients/${encodeURIComponent(clientId)}/capabilities`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ actions: [notifyAction] }),
|
||||
});
|
||||
setConnection("connected", "Capability online");
|
||||
}
|
||||
|
||||
async function answerAction(event) {
|
||||
if (event.action.id !== notifyAction.id || event.action.version !== notifyAction.version) {
|
||||
await postActionResult(event.requestId, { clientId, ok: false, error: "Unsupported action" });
|
||||
return;
|
||||
}
|
||||
const message = event.input?.message;
|
||||
if (typeof message !== "string" || !message) {
|
||||
await postActionResult(event.requestId, { clientId, ok: false, error: "Invalid notify input" });
|
||||
return;
|
||||
}
|
||||
showNotification(message);
|
||||
await postActionResult(event.requestId, { clientId, ok: true, result: { displayed: true } });
|
||||
}
|
||||
|
||||
async function postActionResult(requestId, result) {
|
||||
await api(`/api/action-results/${encodeURIComponent(requestId)}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(result),
|
||||
});
|
||||
}
|
||||
|
||||
function handleEvent(event) {
|
||||
switch (event.type) {
|
||||
case "ready":
|
||||
void registerCapabilities().catch((error) => setConnection("error", error.message));
|
||||
break;
|
||||
case "user_prompt":
|
||||
appendMessage(event.clientId === clientId ? "You" : "Peer", event.text, "user");
|
||||
break;
|
||||
case "assistant_start":
|
||||
assistantContent = undefined;
|
||||
break;
|
||||
case "assistant_delta":
|
||||
if (!assistantContent) assistantContent = appendMessage("Pi", "", "assistant");
|
||||
assistantContent.textContent += event.text;
|
||||
transcript.scrollTop = transcript.scrollHeight;
|
||||
break;
|
||||
case "assistant_end":
|
||||
assistantContent = undefined;
|
||||
break;
|
||||
case "tool_start":
|
||||
appendMessage("Tool", event.name, "tool");
|
||||
break;
|
||||
case "error":
|
||||
appendMessage("Error", event.message, "error");
|
||||
break;
|
||||
case "action_request":
|
||||
void answerAction(event).catch((error) => appendMessage("Error", error.message, "error"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const events = new EventSource(`/api/events?clientId=${encodeURIComponent(clientId)}`);
|
||||
events.onmessage = ({ data }) => handleEvent(JSON.parse(data));
|
||||
events.onerror = () => setConnection("error", "Reconnecting");
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const text = prompt.value.trim();
|
||||
if (!text || busy) return;
|
||||
busy = true;
|
||||
setConnection("connected", "Pi is working");
|
||||
prompt.value = "";
|
||||
try {
|
||||
await api("/api/prompt", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ clientId, text }),
|
||||
});
|
||||
} catch (error) {
|
||||
appendMessage("Error", error instanceof Error ? error.message : String(error), "error");
|
||||
} finally {
|
||||
busy = false;
|
||||
setConnection("connected", "Capability online");
|
||||
prompt.focus();
|
||||
}
|
||||
});
|
||||
|
||||
prompt.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
form.requestSubmit();
|
||||
}
|
||||
});
|
||||
|
||||
testNotify.addEventListener("click", async () => {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
setConnection("connected", "Invoking directly");
|
||||
try {
|
||||
await api("/api/commands/notify", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ clientId, message: "Browser capability confirmed." }),
|
||||
});
|
||||
} catch (error) {
|
||||
appendMessage("Error", error instanceof Error ? error.message : String(error), "error");
|
||||
} finally {
|
||||
busy = false;
|
||||
setConnection("connected", "Capability online");
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("beforeunload", () => events.close());
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<link rel="icon" href="data:," />
|
||||
<title>Pi capability spike</title>
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="page-grid" aria-hidden="true"></div>
|
||||
<header class="masthead">
|
||||
<div>
|
||||
<p class="eyebrow">Client 02 / browser</p>
|
||||
<h1>Pi, loosely coupled.</h1>
|
||||
</div>
|
||||
<div class="connection" data-state="connecting">
|
||||
<span class="connection-dot"></span>
|
||||
<span id="connection-label">Connecting</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="transcript" id="transcript" aria-live="polite" aria-label="Conversation">
|
||||
<div class="empty-state" id="empty-state">
|
||||
<p class="index">01</p>
|
||||
<p>Ask Pi to notify you, or fire the capability directly.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form class="composer" id="prompt-form">
|
||||
<label for="prompt">Prompt</label>
|
||||
<div class="input-row">
|
||||
<textarea id="prompt" name="prompt" rows="2" placeholder="Notify me that the browser capability works" required></textarea>
|
||||
<button type="submit" id="send" disabled>Send</button>
|
||||
</div>
|
||||
<div class="composer-meta">
|
||||
<span>Enter to send · Shift+Enter for newline</span>
|
||||
<button type="button" class="text-button" id="test-notify" disabled>Test notify directly</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<div class="notification-stage" aria-live="assertive" aria-atomic="true">
|
||||
<div class="notification" id="notification" hidden></div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
376
packages/coding-agent/examples/capability-spike/web/styles.css
Normal file
376
packages/coding-agent/examples/capability-spike/web/styles.css
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
@layer reset, base, components, motion;
|
||||
|
||||
@layer reset {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body,
|
||||
h1,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--paper: oklch(0.96 0.025 86);
|
||||
--ink: oklch(0.19 0.025 54);
|
||||
--muted: oklch(0.49 0.04 70);
|
||||
--rule: oklch(0.78 0.055 76);
|
||||
--signal: oklch(0.78 0.18 77);
|
||||
--hot: oklch(0.59 0.23 34);
|
||||
--success: oklch(0.55 0.13 151);
|
||||
font-family: "Avenir Next", "Century Gothic", sans-serif;
|
||||
color: var(--ink);
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
body {
|
||||
min-block-size: 100svh;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
padding: clamp(1rem, 3vw, 2.75rem);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
button,
|
||||
textarea {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: 3px solid var(--hot);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.page-grid {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
background-image:
|
||||
linear-gradient(to right, color-mix(in oklch, var(--rule) 34%, transparent) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, color-mix(in oklch, var(--rule) 25%, transparent) 1px, transparent 1px);
|
||||
background-size: 2rem 2rem;
|
||||
mask-image: linear-gradient(to bottom, black, transparent 78%);
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.masthead {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 2rem;
|
||||
padding-block-end: clamp(2rem, 7vh, 5rem);
|
||||
|
||||
& h1 {
|
||||
max-inline-size: 15ch;
|
||||
font-family: "Iowan Old Style", "Palatino Linotype", Georgia, serif;
|
||||
font-size: clamp(2.75rem, 8vw, 7.5rem);
|
||||
font-weight: 600;
|
||||
font-style: italic;
|
||||
letter-spacing: -0.065em;
|
||||
line-height: 0.84;
|
||||
text-wrap: balance;
|
||||
}
|
||||
}
|
||||
|
||||
.eyebrow,
|
||||
.connection,
|
||||
.composer label,
|
||||
.composer-meta {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin-block-end: 1rem;
|
||||
color: var(--hot);
|
||||
}
|
||||
|
||||
.connection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding-block: 0.4rem;
|
||||
white-space: nowrap;
|
||||
|
||||
& .connection-dot {
|
||||
inline-size: 0.65rem;
|
||||
block-size: 0.65rem;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 50%;
|
||||
background: var(--signal);
|
||||
}
|
||||
|
||||
&[data-state="connected"] .connection-dot {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
&[data-state="error"] .connection-dot {
|
||||
background: var(--hot);
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
inline-size: min(100%, 68rem);
|
||||
margin-inline-start: auto;
|
||||
display: grid;
|
||||
grid-template-rows: minmax(14rem, 1fr) auto;
|
||||
gap: clamp(2rem, 5vh, 4rem);
|
||||
}
|
||||
|
||||
.transcript {
|
||||
align-self: end;
|
||||
max-block-size: 42vh;
|
||||
overflow-y: auto;
|
||||
scrollbar-gutter: stable;
|
||||
display: grid;
|
||||
gap: 1.2rem;
|
||||
padding-inline: 0.25rem 1rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: grid;
|
||||
grid-template-columns: 4rem minmax(0, 26rem);
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
color: var(--muted);
|
||||
font-family: "Iowan Old Style", "Palatino Linotype", Georgia, serif;
|
||||
font-size: clamp(1.15rem, 2.5vw, 1.65rem);
|
||||
line-height: 1.25;
|
||||
|
||||
& .index {
|
||||
font-family: "Avenir Next", "Century Gothic", sans-serif;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
}
|
||||
|
||||
.message {
|
||||
display: grid;
|
||||
grid-template-columns: 4rem minmax(0, 42rem);
|
||||
gap: 1rem;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
|
||||
& .role {
|
||||
padding-block-start: 0.25rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.67rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&.user .content {
|
||||
font-family: "Iowan Old Style", "Palatino Linotype", Georgia, serif;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
&.tool .content {
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
.composer {
|
||||
border-block-start: 2px solid var(--ink);
|
||||
padding-block-start: 0.75rem;
|
||||
|
||||
& label {
|
||||
display: block;
|
||||
margin-block-end: 0.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
.input-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 0.75rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-block-size: 5.5rem;
|
||||
resize: vertical;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0.65rem 0;
|
||||
font-family: "Iowan Old Style", "Palatino Linotype", Georgia, serif;
|
||||
font-size: clamp(1.15rem, 2.2vw, 1.55rem);
|
||||
line-height: 1.35;
|
||||
|
||||
&::placeholder {
|
||||
color: color-mix(in oklch, var(--muted) 65%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#send {
|
||||
min-inline-size: 6.5rem;
|
||||
background: var(--ink);
|
||||
color: var(--paper);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
transition: transform 160ms ease-out, background-color 160ms ease-out;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--hot);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
.composer-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-block-start: 0.75rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.text-button {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--hot);
|
||||
font-weight: 800;
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 0.25rem;
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-stage {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.notification {
|
||||
position: absolute;
|
||||
inset-block-start: 50%;
|
||||
inset-inline-start: 50%;
|
||||
max-inline-size: min(80vw, 46rem);
|
||||
padding: clamp(1rem, 3vw, 2rem) clamp(1.25rem, 5vw, 3.5rem);
|
||||
background: var(--signal);
|
||||
color: var(--ink);
|
||||
box-shadow: 0.8rem 0.8rem 0 var(--hot);
|
||||
font-family: "Iowan Old Style", "Palatino Linotype", Georgia, serif;
|
||||
font-size: clamp(1.75rem, 6vw, 5rem);
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
line-height: 0.95;
|
||||
text-align: center;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
@media (width < 44rem) {
|
||||
body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.masthead {
|
||||
align-items: end;
|
||||
padding-block-end: 3rem;
|
||||
}
|
||||
|
||||
.connection {
|
||||
& #connection-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.message,
|
||||
.empty-state {
|
||||
grid-template-columns: 2.5rem minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.input-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
#send {
|
||||
min-block-size: 3.25rem;
|
||||
}
|
||||
|
||||
.composer-meta {
|
||||
align-items: start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer motion {
|
||||
.notification.is-flying {
|
||||
animation: notify-spin-out 2.35s cubic-bezier(0.77, 0, 0.22, 1) both;
|
||||
}
|
||||
|
||||
@keyframes notify-spin-out {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) rotate(-18deg) scale(0.35);
|
||||
}
|
||||
13% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) rotate(2deg) scale(1.08);
|
||||
}
|
||||
25%,
|
||||
52% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) rotate(0deg) scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(calc(-50% + 112vw), calc(-50% - 82vh)) rotate(1600deg) scale(0.08);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
|
||||
.notification.is-flying {
|
||||
animation: notify-fade 2.35s ease-out both;
|
||||
}
|
||||
|
||||
@keyframes notify-fade {
|
||||
0%, 100% { opacity: 0; transform: translate(-50%, -50%); }
|
||||
15%, 75% { opacity: 1; transform: translate(-50%, -50%); }
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue