e30e75b5d4
Changesets / Create Version PR (push) Has been cancelled
Deploy Shadcn Registry / Deploy Production (push) Has been cancelled
Template Metrics / LOC + Bundle Size (push) Has been cancelled
Code Quality / Oxlint + Oxfmt (push) Has been cancelled
Code Quality / Template Sync (push) Has been cancelled
Code Quality / Build Changed Packages (push) Has been cancelled
Code Quality / Test Changed Packages (push) Has been cancelled
Deploy Expo Example / Deploy Production (push) Has been cancelled
Deploy Ink Example / Deploy Production (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.12) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.12) (push) Has been cancelled
269 lines
13 KiB
Markdown
269 lines
13 KiB
Markdown
# @assistant-ui/react-pi
|
|
|
|
Pi coding-agent runtime adapter for [assistant-ui](https://www.assistant-ui.com/).
|
|
|
|
This package lets assistant-ui render and drive [Pi](https://www.npmjs.com/package/@earendil-works/pi-coding-agent)-backed
|
|
threads: streaming assistant/reasoning output, tool calls with live streaming
|
|
results, mid-run steering and follow-up, per-thread model/thinking controls,
|
|
the blocking extension UI (Pi's entire human-in-the-loop/approval surface), and
|
|
a multi-thread thread list.
|
|
|
|
## Package boundary
|
|
|
|
The package has two entry points:
|
|
|
|
- `@assistant-ui/react-pi` — **browser-safe**. The runtime hook, the pure event
|
|
reducer, the message projection, and the HTTP `PiClient`. This entry **never**
|
|
imports `@earendil-works/pi-*`; it speaks an RPC-isomorphic, JSON-safe
|
|
contract (`PiClient`) over an arbitrary transport.
|
|
- `@assistant-ui/react-pi/node` — **node-only**. `createPiNodeClient`, which
|
|
drives Pi's `AgentSession` SDK in-process behind a process-singleton
|
|
`PiThreadSupervisor`. Only this entry pulls in Pi's Node packages.
|
|
|
|
HTTP is one implementation of `PiClient`, not the contract — the runtime hook
|
|
never bakes in a transport. You can serve the node client over HTTP/SSE (below),
|
|
hand it straight to `usePiRuntime` in a colocated Electron/main-process setup, or
|
|
write any other transport that satisfies `PiClient`.
|
|
|
|
## Install
|
|
|
|
```bash
|
|
npm install @assistant-ui/react-pi @assistant-ui/react
|
|
# the node entry drives the Pi SDK on the server:
|
|
npm install @earendil-works/pi-coding-agent
|
|
```
|
|
|
|
`@earendil-works/pi-coding-agent` is an optional peer dependency — browser-only
|
|
consumers (those importing a remote `PiClient`) never pull it in. The `./node`
|
|
entry needs it.
|
|
|
|
## Quickstart (HTTP/SSE transport)
|
|
|
|
The two halves of one `PiClient` contract:
|
|
|
|
```
|
|
browser server (Node)
|
|
───────── ─────────────
|
|
usePiRuntime route layer (GET/POST /api/pi/**)
|
|
└ createPiHttpClient ──HTTP──▶ createPiNodeClient(...)
|
|
(fetch + SSE) └ PiThreadSupervisor → Pi SDK
|
|
```
|
|
|
|
### 1. Server: the node client
|
|
|
|
`createPiNodeClient` returns a `PiClient`. It owns a **process-singleton**
|
|
supervisor pinned to `globalThis`, so it survives Next.js dev HMR — create it in
|
|
a module-level server file, never per request.
|
|
|
|
```ts
|
|
// lib/pi-server.ts (server-only — imported only from route handlers)
|
|
import { createPiNodeClient } from "@assistant-ui/react-pi/node";
|
|
|
|
export const piClient = createPiNodeClient({
|
|
workspacePath: process.env.PI_WORKSPACE_PATH ?? process.cwd(),
|
|
// agentDir?: override Pi's config dir (default ~/.pi/agent)
|
|
// model?: a resolved Pi `Model` to seed new sessions (see Model controls)
|
|
});
|
|
```
|
|
|
|
### 2. Server: the route layer
|
|
|
|
Expose the `PiClient` methods over HTTP, and `subscribe` over SSE. The wire
|
|
contract `createPiHttpClient` expects (relative to `baseUrl`, default `/api/pi`):
|
|
|
|
```
|
|
GET /threads → PiThreadMetadata[]
|
|
POST /threads → PiThreadSnapshot (body: create input)
|
|
GET /threads/:id → PiThreadSnapshot (read-only snapshot)
|
|
PATCH /threads/:id → 204 (body: { title })
|
|
POST /threads/:id/messages → 204 (body: { input })
|
|
POST /threads/:id/cancel → 204
|
|
GET /models → PiModelInfo[]
|
|
POST /threads/:id/model → 204 (body: { provider, modelId })
|
|
POST /threads/:id/thinking → 204 (body: { level })
|
|
POST /threads/:id/archive → 204
|
|
POST /threads/:id/unarchive → 204
|
|
DELETE /threads/:id → 204
|
|
POST /threads/:id/host-ui → 204 (body: { response })
|
|
GET /threads/:id/events → SSE of PiClientEvent (?snapshot=false skips initial snapshot)
|
|
```
|
|
|
|
The events route must stream `piClient.subscribe(threadId, …)` and unsubscribe on
|
|
request abort. A browser disconnect must **not** abort the run — see Reconnect.
|
|
See [`examples/with-pi/app/api/pi/**`](../../examples/with-pi/app/api/pi) for a
|
|
complete Next.js App Router implementation.
|
|
|
|
### 3. Browser: the runtime
|
|
|
|
```tsx
|
|
"use client";
|
|
import { AssistantRuntimeProvider } from "@assistant-ui/react";
|
|
import { createPiHttpClient, usePiRuntime } from "@assistant-ui/react-pi";
|
|
import { useMemo } from "react";
|
|
|
|
export function PiRuntimeProvider({ children }: { children: React.ReactNode }) {
|
|
const client = useMemo(() => createPiHttpClient(), []); // baseUrl defaults to /api/pi
|
|
const runtime = usePiRuntime({ client /*, workspacePath, includeArchived */ });
|
|
return <AssistantRuntimeProvider runtime={runtime}>{children}</AssistantRuntimeProvider>;
|
|
}
|
|
```
|
|
|
|
`usePiRuntime` requires `options.client` — there is no implicit transport. Drop
|
|
the provider above any assistant-ui thread UI (`Thread`, `ThreadList`, …).
|
|
|
|
## Environment / model resolution
|
|
|
|
A Pi session needs a model and credentials. Resolution mirrors Pi's own
|
|
`createAgentSession`: an explicit `PI_PROVIDER` + `PI_MODEL_ID` wins, otherwise Pi
|
|
falls back to its configured default (`settings.json`'s `defaultProvider` /
|
|
`defaultModel`). A user who is authenticated with `pi` and has a default model
|
|
picked needs **no env at all**.
|
|
|
|
| Variable | Purpose |
|
|
| --------------------- | -------------------------------------------------------------------------------------------- |
|
|
| `PI_PROVIDER` | Override provider for new sessions (e.g. `anthropic`). |
|
|
| `PI_MODEL_ID` | Override model id for new sessions. |
|
|
| `PI_WORKSPACE_PATH` | Working directory the agent reads/writes/runs shell commands in. Point it at a scratch dir. |
|
|
| `PI_CODING_AGENT_DIR` | Pi's agent config dir (`~/.pi/agent` by default). |
|
|
|
|
The `./node` entry takes a resolved Pi `Model` via `createPiNodeClient({ model })`
|
|
to seed new sessions. Use Pi's own `ModelRegistry` / `SettingsManager` /
|
|
`AuthStorage` on the server to resolve it. See
|
|
[`examples/with-pi/lib/pi-server.ts`](../../examples/with-pi/lib/pi-server.ts).
|
|
|
|
## Model & thinking controls
|
|
|
|
Per-thread, surfaced through `usePiRuntimeExtras()`:
|
|
|
|
```tsx
|
|
import { usePiRuntimeExtras } from "@assistant-ui/react-pi";
|
|
|
|
const { readiness, contextUsage, setModel, setThinkingLevel } = usePiRuntimeExtras();
|
|
// readiness: "ready" | "missing-model" | "missing-credentials" | "unavailable-model"
|
|
// await setModel({ provider, modelId });
|
|
// await setThinkingLevel("off" | "minimal" | "low" | "medium" | "high" | "xhigh");
|
|
```
|
|
|
|
`getAvailableModels()` on the client returns the catalog (auth-configured models
|
|
first, falling back to the full built-in list). `setThinkingLevel` is clamped by
|
|
Pi to the model's supported levels; the UI reflects the effective level from the
|
|
next snapshot/event. `contextUsage` (`{ tokens, contextWindow, percent }`)
|
|
answers "am I about to auto-compact?" and is available even for cold threads.
|
|
|
|
`readiness` gates sending — block the composer and show the `readiness.message`
|
|
(a concrete next step, e.g. "Authenticate with `pi`, then restart the server.")
|
|
when it is not `"ready"`.
|
|
|
|
## Composer run semantics
|
|
|
|
Pi's defining interaction is mid-run steering, and a plain `prompt()` while
|
|
streaming **throws**. The runtime exposes Pi's native queue to assistant-ui
|
|
(`capabilities.queue`), so the standard composer keeps accepting input during a
|
|
run and derives the right behavior:
|
|
|
|
| State / action | Behavior |
|
|
| ------------------------------ | ---------------------------------------------------- |
|
|
| idle + submit | `prompt()` |
|
|
| running + Enter | follow-up (`streamingBehavior: "followUp"`) — queued |
|
|
| running + Cmd/Ctrl+Shift+Enter | steer (`streamingBehavior: "steer"`) |
|
|
|
|
To force steer from your own composer, send with `steer: true` or set
|
|
`message.runConfig.custom.streamingBehavior = "steer"`. While running, an
|
|
omitted behavior defaults to `"followUp"`.
|
|
|
|
Queued messages live in Pi's queue, **not** the transcript — Pi appends the
|
|
user message only when the queue flushes. The runtime mirrors them as queue
|
|
items (`s.composer.queue`, renderable with `ComposerPrimitive.Queue`) and on
|
|
the thread metadata's `queuedMessages`. Pi supports clearing the whole queue
|
|
(`clearQueue()` on the client / `usePiRuntimeExtras().clearQueue()`, which
|
|
resolves with the cleared text so it can be restored into the composer), but
|
|
no per-item remove/promote (see Known limitations).
|
|
|
|
## Host-UI requests (the approval surface)
|
|
|
|
Pi has no built-in permission system — the **only** human-in-the-loop mechanism is
|
|
extensions/tools calling `ctx.ui.confirm / select / input / editor`. This package
|
|
implements and binds the `ExtensionUIContext` on the server and routes the four
|
|
blocking dialogs to the UI, split by causality:
|
|
|
|
- **Tool-associated** (a dialog raised while exactly one tool is executing) →
|
|
rendered as a native `ToolCallMessagePart.approval` (confirm) or
|
|
`.interrupt` (select/input/editor), wired through the runtime's
|
|
`onRespondToToolApproval` / `onResumeToolCall`.
|
|
- **Free-standing** (extension commands, or any request raised while multiple
|
|
tools are in flight) → a side channel:
|
|
|
|
```tsx
|
|
import { usePiHostUiRequests } from "@assistant-ui/react-pi";
|
|
|
|
const { requests, respond } = usePiHostUiRequests();
|
|
// confirm: respond({ requestId, confirmed: boolean })
|
|
// select/input/editor: respond({ requestId, value: string })
|
|
// or respond({ requestId, dismissed: true })
|
|
```
|
|
|
|
Pi's UI requests carry no `toolCallId`, so causality is only inferred when a
|
|
single tool is executing; otherwise requests stay on the side channel. Pending
|
|
requests are tracked on the server record (not the SSE connection), so a
|
|
reconnecting client still sees them. Unsupported `ExtensionUIContext` methods
|
|
degrade rather than crash.
|
|
|
|
## Reconnect semantics
|
|
|
|
- The supervisor keeps the runtime alive across browser disconnects — only an
|
|
explicit `cancelRun` or process exit stops a run. **A dropped SSE never aborts.**
|
|
- Every (re)connect is **snapshot-first**: the server re-sends an authoritative
|
|
`snapshot` event, then live events apply on top. There is no event replay in the
|
|
MVP; the snapshot is authoritative.
|
|
- Cold/historical threads load via a **read-only session-file snapshot** — opening
|
|
a thread to read it does **not** spin up a live `AgentSession`. A live runtime is
|
|
created only when you send, cancel, change model/thinking, answer host UI, or
|
|
explicitly subscribe to live events.
|
|
|
|
## Selector hooks
|
|
|
|
The runtime keeps high-frequency transcript state separate from low-frequency
|
|
metadata, so metadata controls don't rerender on every token:
|
|
|
|
- `usePiRuntimeExtras()` — `status`, `readiness`, `contextUsage`, `queue`,
|
|
`compaction`, `retry`, `lastError`, host-UI requests, and the `cancel` /
|
|
`refresh` / `clearQueue` / `setModel` / `setThinkingLevel` / `respondTo*` /
|
|
`resumeToolCall` actions.
|
|
- `usePiSession()` — the current `PiThreadMetadata` (or `null`).
|
|
- `usePiThreadState(selector?)` — the raw reducer state, optionally selected.
|
|
- `usePiHostUiRequests()` — free-standing host-UI requests + a responder.
|
|
|
|
## Known limitations (MVP)
|
|
|
|
- **No RPC-subprocess transport.** The SDK-in-process node client assumes one
|
|
long-lived Node process; it does not survive serverless/edge. The contract is
|
|
RPC-isomorphic so a subprocess/remote transport can drop in later.
|
|
- **No durable event replay / backpressure / version negotiation.** Recovery is
|
|
snapshot-first only.
|
|
- **Idle threads are not auto-followed on view.** Opening a thread is a cheap
|
|
read-only snapshot; the runtime auto-subscribes to live events only when the
|
|
loaded snapshot reports the thread as running (or when you send). A run that
|
|
starts elsewhere *after* you opened an idle view shows up on the next
|
|
refresh, not live. (`controller.connect()` exists for explicit always-live
|
|
subscription.)
|
|
- **The node host's catalog is process-local and in-memory.** It caches
|
|
`SessionManager.list()` and tracks archive state for the running process; nothing
|
|
is persisted across restarts (no last-selected workspace, drafts, labels, or
|
|
search index). Workspace is just a `workspacePath` string on the client — the UI
|
|
for choosing one (text field, directory picker, …) is the consuming app's
|
|
concern, not this package's.
|
|
- **No fork/clone/navigate or per-item queue editing.** The reducer/projection
|
|
preserve the underlying data — tree linkage (`parentSessionPath`), queued
|
|
messages (`queuedMessages`), and compaction state — so a consumer can build those
|
|
surfaces, but the client exposes no methods to fork a session, navigate the
|
|
tree, or reorder the queue. Queue items mirror Pi's server-side queue:
|
|
enqueueing works (that's how mid-run follow-up/steer is sent) and clearing
|
|
all works (`clearQueue`, mirroring Pi's only queue mutation), but per-item
|
|
steer/remove affordances are no-ops — Pi has no such API.
|
|
- **Attachments are image-passthrough only.** Non-image input parts aren't
|
|
converted into Pi user content.
|
|
- **Model/thinking are the only runtime-config actions.** There are no
|
|
provider/auth/credential or default-model/settings methods on the client — manage
|
|
those with Pi's own SDK on the server. Skills/extensions/slash-commands aren't
|
|
surfaced.
|